Compare commits
No commits in common. "main" and "v1.6.0-rc4" have entirely different histories.
main
...
v1.6.0-rc4
52 changed files with 312 additions and 6596 deletions
|
|
@ -6,13 +6,12 @@ name: Release
|
||||||
- "**"
|
- "**"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
releases: write
|
releases: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
@ -20,55 +19,38 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Extract changelog and update CHANGELOG.md
|
- name: Build changelog
|
||||||
id: changelog
|
id: changelog
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
current_tag="${GITHUB_REF_NAME}"
|
current_tag="${GITHUB_REF_NAME}"
|
||||||
today=$(date +%Y-%m-%d)
|
previous_stable_tag=""
|
||||||
|
|
||||||
# Extract content of [Unreleased] section (non-empty lines)
|
if previous_stable_tag="$(
|
||||||
release_notes=$(awk '/^## \[Unreleased\]/{found=1; next} found && /^## \[/{exit} found{print}' CHANGELOG.md | sed '/^[[:space:]]*$/d')
|
git describe --tags --abbrev=0 \
|
||||||
|
--exclude '*-rc*' \
|
||||||
if [ -z "${release_notes}" ]; then
|
--exclude '*-beta*' \
|
||||||
release_notes="Voir les commits pour le détail des changements."
|
--exclude '*-alpha*' \
|
||||||
|
"${current_tag}^" 2>/dev/null
|
||||||
|
)"; then
|
||||||
|
range="${previous_stable_tag}..${current_tag}"
|
||||||
|
{
|
||||||
|
printf '## Changelog\n\n'
|
||||||
|
printf 'Changes since `%s`.\n\n' "${previous_stable_tag}"
|
||||||
|
git log --reverse --pretty=format:'- %h %s' "${range}"
|
||||||
|
printf '\n'
|
||||||
|
} >CHANGELOG.md
|
||||||
|
else
|
||||||
|
{
|
||||||
|
printf '## Changelog\n\n'
|
||||||
|
printf 'Initial release.\n\n'
|
||||||
|
git log --reverse --pretty=format:'- %h %s' "${current_tag}"
|
||||||
|
printf '\n'
|
||||||
|
} >CHANGELOG.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '%s\n' "${release_notes}" > release_notes.md
|
|
||||||
|
|
||||||
# For stable releases: rename [Unreleased] and insert a new empty section
|
|
||||||
case "${current_tag}" in
|
|
||||||
*-rc*|*-beta*|*-alpha*)
|
|
||||||
echo "Pre-release tag — CHANGELOG.md non modifié"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
# Rename [Unreleased] → version header
|
|
||||||
sed -i "s/^## \[Unreleased\]$/## [${current_tag}] — ${today}/" CHANGELOG.md
|
|
||||||
|
|
||||||
# Insert new empty [Unreleased] section after "# Changelog"
|
|
||||||
sed -i "s/^# Changelog$/# Changelog\n\n## [Unreleased]/" CHANGELOG.md
|
|
||||||
|
|
||||||
# Insert reference link before the first existing [vX...] link
|
|
||||||
awk -v tag="${current_tag}" -v url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${current_tag}" '
|
|
||||||
!inserted && /^\[v/ { print "[" tag "]: " url; inserted=1 }
|
|
||||||
{ print }
|
|
||||||
' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md
|
|
||||||
|
|
||||||
git config user.name "CI"
|
|
||||||
git config user.email "ci@forge.lclr.dev"
|
|
||||||
scheme="${GITHUB_SERVER_URL%%://*}"
|
|
||||||
host="${GITHUB_SERVER_URL#*://}"
|
|
||||||
git remote set-url origin "${scheme}://x-token:${GITEA_TOKEN}@${host}/${GITHUB_REPOSITORY}.git"
|
|
||||||
git add CHANGELOG.md
|
|
||||||
git commit -m "chore(changelog): release ${current_tag}"
|
|
||||||
git push origin HEAD:main
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
- name: Create or update release
|
- name: Create or update release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
@ -87,32 +69,11 @@ jobs:
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Build commit log since previous stable tag
|
|
||||||
previous_stable_tag=""
|
|
||||||
if previous_stable_tag="$(
|
|
||||||
git describe --tags --abbrev=0 \
|
|
||||||
--exclude '*-rc*' \
|
|
||||||
--exclude '*-beta*' \
|
|
||||||
--exclude '*-alpha*' \
|
|
||||||
"${current_tag}^" 2>/dev/null
|
|
||||||
)"; then
|
|
||||||
range="${previous_stable_tag}..${current_tag}"
|
|
||||||
else
|
|
||||||
range="${current_tag}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
cat release_notes.md
|
|
||||||
printf '\n\n## Commits\n\n'
|
|
||||||
git log --reverse --pretty=format:'- %h %s' "${range}"
|
|
||||||
printf '\n'
|
|
||||||
} > release_body.md
|
|
||||||
|
|
||||||
json_escape() {
|
json_escape() {
|
||||||
sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g'
|
sed ':a;N;$!ba;s/\\/\\\\/g;s/"/\\"/g;s/\t/\\t/g;s/\r//g;s/\n/\\n/g'
|
||||||
}
|
}
|
||||||
|
|
||||||
body="$(json_escape < release_body.md)"
|
body="$(json_escape < CHANGELOG.md)"
|
||||||
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
|
payload="$(printf '{"tag_name":"%s","name":"%s","body":"%s","draft":false,"prerelease":%s}' \
|
||||||
"${current_tag}" \
|
"${current_tag}" \
|
||||||
"${current_tag}" \
|
"${current_tag}" \
|
||||||
|
|
@ -60,11 +60,3 @@ Avant d'ouvrir ou de mettre à jour une PR :
|
||||||
## Commits
|
## Commits
|
||||||
|
|
||||||
Conserver des messages de commit au format conventional commits, conformément aux règles globales.
|
Conserver des messages de commit au format conventional commits, conformément aux règles globales.
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
Après chaque développement fonctionnel, mettre à jour la section `## [Unreleased]` du `CHANGELOG.md` avec une description claire des changements apportés.
|
|
||||||
|
|
||||||
Ne pas logger les changements purement liés à la CI ou au changelog lui-même.
|
|
||||||
|
|
||||||
La CI se charge de versioner automatiquement la section `[Unreleased]` lors du push d'un tag stable.
|
|
||||||
|
|
|
||||||
240
CHANGELOG.md
240
CHANGELOG.md
|
|
@ -1,240 +0,0 @@
|
||||||
# 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 +0,0 @@
|
||||||
AGENTS.md
|
|
||||||
96
README.md
96
README.md
|
|
@ -1,90 +1,52 @@
|
||||||
# mcp-framework
|
# mcp-framework
|
||||||
|
|
||||||
`mcp-framework` est une bibliothèque Go et un petit CLI pour construire des
|
`mcp-framework` est une bibliothèque Go pour construire des binaires MCP robustes, sans imposer un runtime lourd.
|
||||||
binaires MCP avec une base commune : CLI, configuration locale, secrets,
|
|
||||||
manifeste `mcp.toml`, diagnostic et auto-update.
|
|
||||||
|
|
||||||
## Installation
|
## Le principal à savoir
|
||||||
|
|
||||||
Dans un projet Go :
|
- Le framework fournit des briques réutilisables : config locale, secrets, résolution CLI, manifeste projet, et auto-update.
|
||||||
|
- Il peut être utilisé de manière modulaire (package par package) ou avec un bootstrap CLI prêt à l'emploi.
|
||||||
|
- Il inclut un générateur de squelette (`mcp-framework scaffold init`) pour démarrer un nouveau binaire MCP rapidement.
|
||||||
|
- Toute la documentation détaillée est maintenant organisée dans `docs/` par grandes parties.
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
Installer le framework dans un projet Go existant :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get forge.lclr.dev/AI/mcp-framework
|
go get gitea.lclr.dev/AI/mcp-framework
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour utiliser le CLI :
|
Initialiser un nouveau projet MCP depuis un dossier vide :
|
||||||
|
|
||||||
```bash
|
|
||||||
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Créer un projet MCP
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||||
mcp-framework scaffold init \
|
mcp-framework scaffold init \
|
||||||
--target ./my-mcp \
|
--target ./my-mcp \
|
||||||
--module example.com/my-mcp \
|
--module example.com/my-mcp \
|
||||||
--binary my-mcp \
|
--binary my-mcp \
|
||||||
--profiles dev,prod
|
--profiles dev,prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis dans le projet généré :
|
||||||
|
|
||||||
|
```bash
|
||||||
cd my-mcp
|
cd my-mcp
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run ./cmd/my-mcp help
|
go run ./cmd/my-mcp help
|
||||||
```
|
```
|
||||||
|
|
||||||
Le scaffold crée une arborescence prête à adapter :
|
|
||||||
|
|
||||||
```text
|
|
||||||
cmd/<binary>/main.go
|
|
||||||
internal/app/app.go
|
|
||||||
mcp.toml
|
|
||||||
install.sh
|
|
||||||
README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Générer la glue depuis `mcp.toml`
|
|
||||||
|
|
||||||
Dans un projet qui possède un `mcp.toml` à la racine :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mcp-framework generate
|
|
||||||
```
|
|
||||||
|
|
||||||
La commande génère un package `mcpgen/` avec un loader de manifeste embarqué,
|
|
||||||
des helpers de métadonnées, update, secret store, et des helpers de config si
|
|
||||||
`[[config.fields]]` est déclaré.
|
|
||||||
|
|
||||||
En CI :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mcp-framework generate --check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Utiliser les packages
|
|
||||||
|
|
||||||
Les packages peuvent être utilisés séparément :
|
|
||||||
|
|
||||||
- `bootstrap` : CLI commune (`setup`, `login`, `mcp`, `config`, `update`, `version`).
|
|
||||||
- `cli` : résolution de profil, setup interactif, résolution `flag/env/config/secret`, doctor.
|
|
||||||
- `config` : stockage JSON versionné dans le répertoire de config utilisateur.
|
|
||||||
- `manifest` : lecture de `mcp.toml` et fallback embarqué.
|
|
||||||
- `secretstore` : keyring natif, environnement ou Bitwarden CLI.
|
|
||||||
- `update` : téléchargement et remplacement du binaire depuis une release.
|
|
||||||
- `scaffold` : génération d'un squelette de projet.
|
|
||||||
- `generate` : génération de code Go depuis `mcp.toml`.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Vue d'ensemble](docs/README.md)
|
- Vue d'ensemble : [docs/README.md](docs/README.md)
|
||||||
- [Installation et utilisation](docs/getting-started.md)
|
- Installation et usage type : [docs/getting-started.md](docs/getting-started.md)
|
||||||
- [Packages](docs/packages.md)
|
- Packages : [docs/packages.md](docs/packages.md)
|
||||||
- [Bootstrap CLI](docs/bootstrap-cli.md)
|
- Bootstrap CLI : [docs/bootstrap-cli.md](docs/bootstrap-cli.md)
|
||||||
- [Manifeste `mcp.toml`](docs/manifest.md)
|
- Manifeste `mcp.toml` : [docs/manifest.md](docs/manifest.md)
|
||||||
- [Génération depuis `mcp.toml`](docs/generate.md)
|
- Scaffolding : [docs/scaffolding.md](docs/scaffolding.md)
|
||||||
- [Scaffolding](docs/scaffolding.md)
|
- Config JSON : [docs/config.md](docs/config.md)
|
||||||
- [Config JSON](docs/config.md)
|
- Secrets : [docs/secrets.md](docs/secrets.md)
|
||||||
- [Secrets](docs/secrets.md)
|
- Helpers CLI : [docs/cli-helpers.md](docs/cli-helpers.md)
|
||||||
- [Helpers CLI](docs/cli-helpers.md)
|
- Auto-update : [docs/auto-update.md](docs/auto-update.md)
|
||||||
- [Auto-update](docs/auto-update.md)
|
- Exemple minimal : [docs/minimal-example.md](docs/minimal-example.md)
|
||||||
- [Exemple minimal](docs/minimal-example.md)
|
- Limites actuelles : [docs/limitations.md](docs/limitations.md)
|
||||||
- [Limites](docs/limitations.md)
|
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,12 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CommandSetup = "setup"
|
CommandSetup = "setup"
|
||||||
CommandLogin = "login"
|
|
||||||
CommandMCP = "mcp"
|
CommandMCP = "mcp"
|
||||||
CommandConfig = "config"
|
CommandConfig = "config"
|
||||||
CommandUpdate = "update"
|
CommandUpdate = "update"
|
||||||
CommandVersion = "version"
|
CommandVersion = "version"
|
||||||
CommandDoctor = "doctor"
|
CommandDoctor = "doctor"
|
||||||
|
|
||||||
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
|
|
||||||
|
|
||||||
ConfigSubcommandShow = "show"
|
ConfigSubcommandShow = "show"
|
||||||
ConfigSubcommandTest = "test"
|
ConfigSubcommandTest = "test"
|
||||||
ConfigSubcommandDelete = "delete"
|
ConfigSubcommandDelete = "delete"
|
||||||
|
|
@ -39,7 +36,6 @@ type Handler func(context.Context, Invocation) error
|
||||||
|
|
||||||
type Hooks struct {
|
type Hooks struct {
|
||||||
Setup Handler
|
Setup Handler
|
||||||
Login Handler
|
|
||||||
MCP Handler
|
MCP Handler
|
||||||
Config Handler
|
Config Handler
|
||||||
ConfigShow Handler
|
ConfigShow Handler
|
||||||
|
|
@ -50,18 +46,17 @@ type Hooks struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
BinaryName string
|
BinaryName string
|
||||||
Description string
|
Description string
|
||||||
Version string
|
Version string
|
||||||
Aliases map[string][]string
|
Aliases map[string][]string
|
||||||
AliasDescriptions map[string]string
|
AliasDescriptions map[string]string
|
||||||
EnableDoctorAlias bool
|
EnableDoctorAlias bool
|
||||||
DisabledCommands []string
|
Args []string
|
||||||
Args []string
|
Stdin io.Reader
|
||||||
Stdin io.Reader
|
Stdout io.Writer
|
||||||
Stdout io.Writer
|
Stderr io.Writer
|
||||||
Stderr io.Writer
|
Hooks Hooks
|
||||||
Hooks Hooks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invocation struct {
|
type Invocation struct {
|
||||||
|
|
@ -86,13 +81,6 @@ var commands = []commandDef{
|
||||||
return h.Setup
|
return h.Setup
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: CommandLogin,
|
|
||||||
Description: "Authentifier et deverrouiller Bitwarden pour persister BW_SESSION.",
|
|
||||||
Handler: func(h Hooks) Handler {
|
|
||||||
return h.Login
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: CommandMCP,
|
Name: CommandMCP,
|
||||||
Description: "Executer la logique MCP principale du binaire.",
|
Description: "Executer la logique MCP principale du binaire.",
|
||||||
|
|
@ -130,13 +118,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||||
return ErrBinaryNameRequired
|
return ErrBinaryNameRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedArgs := expandAliases(normalized.Args, normalized.Aliases)
|
command, commandArgs, showHelp := parseArgs(expandAliases(normalized.Args, normalized.Aliases))
|
||||||
resolvedArgs, debugEnabled := extractGlobalDebugFlag(resolvedArgs)
|
|
||||||
if debugEnabled {
|
|
||||||
_ = os.Setenv(bitwardenDebugEnvName, "1")
|
|
||||||
}
|
|
||||||
|
|
||||||
command, commandArgs, showHelp := parseArgs(resolvedArgs)
|
|
||||||
if showHelp {
|
if showHelp {
|
||||||
return printHelp(normalized, command, commandArgs)
|
return printHelp(normalized, command, commandArgs)
|
||||||
}
|
}
|
||||||
|
|
@ -145,10 +127,6 @@ func Run(ctx context.Context, opts Options) error {
|
||||||
return printHelp(normalized, "")
|
return printHelp(normalized, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
if isCommandDisabled(command, normalized.DisabledCommands) {
|
|
||||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
|
||||||
}
|
|
||||||
|
|
||||||
if command == CommandConfig {
|
if command == CommandConfig {
|
||||||
return runConfigCommand(ctx, normalized, commandArgs)
|
return runConfigCommand(ctx, normalized, commandArgs)
|
||||||
}
|
}
|
||||||
|
|
@ -159,16 +137,14 @@ func Run(ctx context.Context, opts Options) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if handler == nil {
|
if handler == nil {
|
||||||
switch command {
|
if command == CommandVersion {
|
||||||
case CommandVersion:
|
|
||||||
if strings.TrimSpace(normalized.Version) == "" {
|
if strings.TrimSpace(normalized.Version) == "" {
|
||||||
return ErrVersionRequired
|
return ErrVersionRequired
|
||||||
}
|
}
|
||||||
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
||||||
return err
|
return err
|
||||||
default:
|
|
||||||
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler(ctx, Invocation{
|
return handler(ctx, Invocation{
|
||||||
|
|
@ -195,11 +171,6 @@ func normalize(opts Options) Options {
|
||||||
}
|
}
|
||||||
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
|
opts.Aliases = normalizeAliases(opts.Aliases, opts.EnableDoctorAlias)
|
||||||
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias)
|
opts.AliasDescriptions = normalizeAliasDescriptions(opts.AliasDescriptions, opts.Aliases, opts.EnableDoctorAlias)
|
||||||
for _, cmd := range autoDisabledCommands(opts) {
|
|
||||||
if !isCommandDisabled(cmd, opts.DisabledCommands) {
|
|
||||||
opts.DisabledCommands = append(opts.DisabledCommands, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,25 +252,6 @@ func parseArgs(args []string) (command string, commandArgs []string, showHelp bo
|
||||||
return command, commandArgs, false
|
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 {
|
func expandAliases(args []string, aliases map[string][]string) []string {
|
||||||
args = trimArgs(args)
|
args = trimArgs(args)
|
||||||
if len(args) == 0 || len(aliases) == 0 {
|
if len(args) == 0 || len(aliases) == 0 {
|
||||||
|
|
@ -456,10 +408,6 @@ func printHelp(opts Options, command string, args ...[]string) error {
|
||||||
return printGlobalHelp(opts)
|
return printGlobalHelp(opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isCommandDisabled(command, opts.DisabledCommands) {
|
|
||||||
return fmt.Errorf("%w: %s", ErrUnknownCommand, command)
|
|
||||||
}
|
|
||||||
|
|
||||||
if command == CommandConfig {
|
if command == CommandConfig {
|
||||||
return printConfigHelp(opts, commandArgs)
|
return printConfigHelp(opts, commandArgs)
|
||||||
}
|
}
|
||||||
|
|
@ -539,9 +487,6 @@ func printGlobalHelp(opts Options) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, def := range commands {
|
for _, def := range commands {
|
||||||
if isCommandDisabled(def.Name, opts.DisabledCommands) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil {
|
if _, err := fmt.Fprintf(opts.Stdout, " %-7s %s\n", def.Name, def.Description); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -567,50 +512,10 @@ func printGlobalHelp(opts Options) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
_, err := fmt.Fprintf(opts.Stdout, "\nAide detaillee: %s help <command>\n", opts.BinaryName)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func autoDisabledCommands(opts Options) []string {
|
|
||||||
h := opts.Hooks
|
|
||||||
var disabled []string
|
|
||||||
if h.Setup == nil {
|
|
||||||
disabled = append(disabled, CommandSetup)
|
|
||||||
}
|
|
||||||
if h.Login == nil {
|
|
||||||
disabled = append(disabled, CommandLogin)
|
|
||||||
}
|
|
||||||
if h.MCP == nil {
|
|
||||||
disabled = append(disabled, CommandMCP)
|
|
||||||
}
|
|
||||||
if h.Config == nil && h.ConfigShow == nil && h.ConfigTest == nil && h.ConfigDelete == nil {
|
|
||||||
disabled = append(disabled, CommandConfig)
|
|
||||||
}
|
|
||||||
if h.Update == nil {
|
|
||||||
disabled = append(disabled, CommandUpdate)
|
|
||||||
}
|
|
||||||
if h.Version == nil && strings.TrimSpace(opts.Version) == "" {
|
|
||||||
disabled = append(disabled, CommandVersion)
|
|
||||||
}
|
|
||||||
return disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCommandDisabled(command string, disabled []string) bool {
|
|
||||||
for _, d := range disabled {
|
|
||||||
if d == command {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func aliasDescription(descriptions map[string]string, name, target string) string {
|
func aliasDescription(descriptions map[string]string, name, target string) string {
|
||||||
description := strings.TrimSpace(descriptions[name])
|
description := strings.TrimSpace(descriptions[name])
|
||||||
if description == "" {
|
if description == "" {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -41,37 +40,6 @@ 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) {
|
func TestRunReturnsUnknownCommand(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
@ -91,13 +59,11 @@ func TestRunReturnsCommandNotConfigured(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Args: []string{"config"},
|
Args: []string{"config"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{ConfigShow: noop},
|
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrSubcommandRequired) {
|
if !errors.Is(err, ErrSubcommandRequired) {
|
||||||
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
|
t.Fatalf("Run error = %v, want ErrSubcommandRequired", err)
|
||||||
|
|
@ -150,7 +116,7 @@ func TestRunVersionHookOverridesDefault(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
|
func TestRunRequiresVersionWithoutVersionHook(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
|
@ -160,8 +126,8 @@ func TestRunVersionAutoHiddenWithoutHookOrVersionString(t *testing.T) {
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrUnknownCommand) {
|
if !errors.Is(err, ErrVersionRequired) {
|
||||||
t.Fatalf("Run error = %v, want ErrUnknownCommand", err)
|
t.Fatalf("Run error = %v, want ErrVersionRequired", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,15 +135,12 @@ func TestRunPrintsGlobalHelp(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Description: "Binaire MCP de test.",
|
Description: "Binaire MCP de test.",
|
||||||
Version: "v1.2.3",
|
|
||||||
Args: []string{"help"},
|
Args: []string{"help"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run error = %v", err)
|
t.Fatalf("Run error = %v", err)
|
||||||
|
|
@ -202,15 +165,12 @@ func TestRunPrintsGlobalHelpWhenNoArgs(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Description: "Binaire MCP de test.",
|
Description: "Binaire MCP de test.",
|
||||||
Version: "v1.2.3",
|
|
||||||
Args: []string{},
|
Args: []string{},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{Setup: noop, MCP: noop, ConfigShow: noop, Update: noop},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run error = %v", err)
|
t.Fatalf("Run error = %v", err)
|
||||||
|
|
@ -235,13 +195,11 @@ func TestRunPrintsCommandHelp(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Args: []string{"help", "update"},
|
Args: []string{"help", "update"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{Update: noop},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run error = %v", err)
|
t.Fatalf("Run error = %v", err)
|
||||||
|
|
@ -256,42 +214,15 @@ func TestRunPrintsCommandHelp(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestRunPrintsConfigHelp(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Args: []string{"help", "config"},
|
Args: []string{"help", "config"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{ConfigShow: noop},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run error = %v", err)
|
t.Fatalf("Run error = %v", err)
|
||||||
|
|
@ -313,13 +244,11 @@ func TestRunPrintsConfigSubcommandHelp(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Args: []string{"help", "config", "show"},
|
Args: []string{"help", "config", "show"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{ConfigShow: noop},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run error = %v", err)
|
t.Fatalf("Run error = %v", err)
|
||||||
|
|
@ -365,13 +294,11 @@ func TestRunConfigShowReturnsCommandNotConfigured(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Args: []string{"config", "show"},
|
Args: []string{"config", "show"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{ConfigTest: noop},
|
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrCommandNotConfigured) {
|
if !errors.Is(err, ErrCommandNotConfigured) {
|
||||||
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
|
t.Fatalf("Run error = %v, want ErrCommandNotConfigured", err)
|
||||||
|
|
@ -382,13 +309,11 @@ func TestRunConfigReturnsUnknownSubcommand(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Args: []string{"config", "sync"},
|
Args: []string{"config", "sync"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{ConfigShow: noop},
|
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrUnknownSubcommand) {
|
if !errors.Is(err, ErrUnknownSubcommand) {
|
||||||
t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err)
|
t.Fatalf("Run error = %v, want ErrUnknownSubcommand", err)
|
||||||
|
|
@ -432,7 +357,6 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Aliases: map[string][]string{
|
Aliases: map[string][]string{
|
||||||
|
|
@ -441,7 +365,6 @@ func TestRunPrintsAliasHelpFromHelpCommand(t *testing.T) {
|
||||||
Args: []string{"help", "doctor"},
|
Args: []string{"help", "doctor"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{ConfigTest: noop},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run error = %v", err)
|
t.Fatalf("Run error = %v", err)
|
||||||
|
|
@ -457,7 +380,6 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
noop := func(_ context.Context, _ Invocation) error { return nil }
|
|
||||||
err := Run(context.Background(), Options{
|
err := Run(context.Background(), Options{
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Aliases: map[string][]string{
|
Aliases: map[string][]string{
|
||||||
|
|
@ -466,7 +388,6 @@ func TestRunPrintsAliasHelpFromTrailingHelpFlag(t *testing.T) {
|
||||||
Args: []string{"doctor", "--help"},
|
Args: []string{"doctor", "--help"},
|
||||||
Stdout: &stdout,
|
Stdout: &stdout,
|
||||||
Stderr: &stderr,
|
Stderr: &stderr,
|
||||||
Hooks: Hooks{ConfigTest: noop},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run error = %v", err)
|
t.Fatalf("Run error = %v", err)
|
||||||
|
|
@ -561,128 +482,3 @@ func TestRunPrintsDoctorAliasInGlobalHelpWhenEnabled(t *testing.T) {
|
||||||
t.Fatalf("global help output missing default doctor alias details: %q", text)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/config"
|
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DoctorStatus string
|
type DoctorStatus string
|
||||||
|
|
@ -65,7 +65,6 @@ type DoctorOptions struct {
|
||||||
|
|
||||||
type BitwardenDoctorOptions struct {
|
type BitwardenDoctorOptions struct {
|
||||||
Command string
|
Command string
|
||||||
Debug bool
|
|
||||||
Shell string
|
Shell string
|
||||||
LookupEnv func(string) (string, bool)
|
LookupEnv func(string) (string, bool)
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +85,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
||||||
}
|
}
|
||||||
if options.ManifestCheck != nil {
|
if options.ManifestCheck != nil {
|
||||||
checks = append(checks, options.ManifestCheck)
|
checks = append(checks, options.ManifestCheck)
|
||||||
} else if strings.TrimSpace(options.ManifestDir) != "" {
|
} else {
|
||||||
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
checks = append(checks, ManifestCheck(options.ManifestDir, options.ManifestValidator))
|
||||||
}
|
}
|
||||||
if options.ConnectivityCheck != nil {
|
if options.ConnectivityCheck != nil {
|
||||||
|
|
@ -239,7 +238,6 @@ func BitwardenReadyCheck(options BitwardenDoctorOptions) DoctorCheck {
|
||||||
return func(context.Context) DoctorResult {
|
return func(context.Context) DoctorResult {
|
||||||
err := checkBitwardenReady(secretstore.Options{
|
err := checkBitwardenReady(secretstore.Options{
|
||||||
BitwardenCommand: strings.TrimSpace(options.Command),
|
BitwardenCommand: strings.TrimSpace(options.Command),
|
||||||
BitwardenDebug: options.Debug,
|
|
||||||
Shell: strings.TrimSpace(options.Shell),
|
Shell: strings.TrimSpace(options.Shell),
|
||||||
LookupEnv: options.LookupEnv,
|
LookupEnv: options.LookupEnv,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/config"
|
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type doctorProfile struct {
|
type doctorProfile struct {
|
||||||
|
|
@ -198,24 +198,6 @@ func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunDoctorOmitsManifestCheckWhenDirNotSet(t *testing.T) {
|
|
||||||
report := RunDoctor(context.Background(), DoctorOptions{
|
|
||||||
ConnectivityCheck: func(context.Context) DoctorResult {
|
|
||||||
return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "ok"}
|
|
||||||
},
|
|
||||||
// ManifestDir intentionally empty, ManifestCheck intentionally nil.
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, r := range report.Results {
|
|
||||||
if r.Name == "manifest" {
|
|
||||||
t.Fatalf("manifest check should not be included when ManifestDir is empty, got: %+v", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(report.Results) != 1 {
|
|
||||||
t.Fatalf("result count = %d, want 1 (connectivity only)", len(report.Results))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
|
func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) {
|
||||||
prev := checkBitwardenReady
|
prev := checkBitwardenReady
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KeyLookupFunc func(key string) (string, bool, error)
|
type KeyLookupFunc func(key string) (string, bool, error)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testSecretStore struct {
|
type testSecretStore struct {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SetupSecretWriteOptions struct {
|
type SetupSecretWriteOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) {
|
func TestWriteSetupSecretVerifiedPersistsAndConfirmsReadability(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,9 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
generatepkg "forge.lclr.dev/AI/mcp-framework/generate"
|
scaffoldpkg "gitea.lclr.dev/AI/mcp-framework/scaffold"
|
||||||
scaffoldpkg "forge.lclr.dev/AI/mcp-framework/scaffold"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const toolName = "mcp-framework"
|
const toolName = "mcp-framework"
|
||||||
|
|
@ -36,8 +34,6 @@ func run(args []string, stdout, stderr io.Writer) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "generate":
|
|
||||||
return runGenerate(args[1:], stdout, stderr)
|
|
||||||
case "scaffold":
|
case "scaffold":
|
||||||
return runScaffold(args[1:], stdout, stderr)
|
return runScaffold(args[1:], stdout, stderr)
|
||||||
default:
|
default:
|
||||||
|
|
@ -45,60 +41,6 @@ func run(args []string, stdout, stderr io.Writer) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runGenerate(args []string, stdout, stderr io.Writer) error {
|
|
||||||
if shouldShowHelp(args) {
|
|
||||||
printGenerateHelp(stdout)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(io.Discard)
|
|
||||||
|
|
||||||
var manifestPath string
|
|
||||||
var packageDir string
|
|
||||||
var packageName string
|
|
||||||
var check bool
|
|
||||||
|
|
||||||
fs.StringVar(&manifestPath, "manifest", "", "Chemin du mcp.toml à lire (défaut: ./mcp.toml)")
|
|
||||||
fs.StringVar(&packageDir, "package-dir", "mcpgen", "Répertoire du package Go généré")
|
|
||||||
fs.StringVar(&packageName, "package-name", "", "Nom du package Go généré (défaut: dérivé du dossier)")
|
|
||||||
fs.BoolVar(&check, "check", false, "Échoue si les fichiers générés ne sont pas à jour")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
_ = stderr
|
|
||||||
return fmt.Errorf("parse generate flags: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if fs.NArg() > 0 {
|
|
||||||
return fmt.Errorf("unexpected argument(s): %s", strings.Join(fs.Args(), ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := generatepkg.Generate(generatepkg.Options{
|
|
||||||
ManifestPath: manifestPath,
|
|
||||||
PackageDir: packageDir,
|
|
||||||
PackageName: packageName,
|
|
||||||
Check: check,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if check {
|
|
||||||
if _, err := fmt.Fprintln(stdout, "Generated files are up to date"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range result.Files {
|
|
||||||
if _, err := fmt.Fprintf(stdout, "Generated %s\n", filepath.ToSlash(file)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runScaffold(args []string, stdout, stderr io.Writer) error {
|
func runScaffold(args []string, stdout, stderr io.Writer) error {
|
||||||
if len(args) == 0 || isHelpArg(args[0]) {
|
if len(args) == 0 || isHelpArg(args[0]) {
|
||||||
printScaffoldHelp(stdout)
|
printScaffoldHelp(stdout)
|
||||||
|
|
@ -203,20 +145,12 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
|
||||||
func printGlobalHelp(w io.Writer) {
|
func printGlobalHelp(w io.Writer) {
|
||||||
fmt.Fprintf(
|
fmt.Fprintf(
|
||||||
w,
|
w,
|
||||||
"Usage:\n %s <command> [options]\n\nCommands:\n 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",
|
"Usage:\n %s <command> [options]\n\nCommands:\n scaffold init Génère un nouveau projet MCP\n\nUse `%s help` for this message.\n",
|
||||||
toolName,
|
toolName,
|
||||||
toolName,
|
toolName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func printGenerateHelp(w io.Writer) {
|
|
||||||
fmt.Fprintf(
|
|
||||||
w,
|
|
||||||
"Usage:\n %s generate [flags]\n\nFlags:\n --manifest Chemin du mcp.toml à lire\n --package-dir Répertoire du package Go généré (défaut: mcpgen)\n --package-name Nom du package Go généré (défaut: dérivé du dossier)\n --check Vérifie que les fichiers générés sont à jour\n",
|
|
||||||
toolName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printScaffoldHelp(w io.Writer) {
|
func printScaffoldHelp(w io.Writer) {
|
||||||
fmt.Fprintf(
|
fmt.Fprintf(
|
||||||
w,
|
w,
|
||||||
|
|
|
||||||
|
|
@ -60,46 +60,6 @@ func TestRunScaffoldInitCreatesProject(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunGenerateCreatesManifestLoader(t *testing.T) {
|
|
||||||
projectDir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile manifest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
|
|
||||||
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml")}, &stdout, &stderr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("run returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(projectDir, "mcpgen", "manifest.go")); err != nil {
|
|
||||||
t.Fatalf("generated manifest.go missing: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(stdout.String(), "Generated mcpgen/manifest.go") {
|
|
||||||
t.Fatalf("stdout should include generation summary: %q", stdout.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunGenerateCheckReturnsErrorWhenOutdated(t *testing.T) {
|
|
||||||
projectDir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile manifest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
|
|
||||||
err := run([]string{"generate", "--manifest", filepath.Join(projectDir, "mcp.toml"), "--check"}, &stdout, &stderr)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "generated files are not up to date") {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunScaffoldInitRequiresTarget(t *testing.T) {
|
func TestRunScaffoldInitRequiresTarget(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
# Documentation mcp-framework
|
# Documentation mcp-framework
|
||||||
|
|
||||||
`mcp-framework` fournit des packages Go et un CLI pour construire des binaires
|
Cette documentation est organisée par grandes parties pour séparer la vue d'ensemble des détails d'implémentation.
|
||||||
MCP avec une base commune : bootstrap CLI, configuration, secrets, manifeste,
|
|
||||||
génération de code, scaffold, diagnostic et auto-update.
|
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
|
|
@ -10,11 +8,10 @@ génération de code, scaffold, diagnostic et auto-update.
|
||||||
- [Packages](packages.md)
|
- [Packages](packages.md)
|
||||||
- [Bootstrap CLI](bootstrap-cli.md)
|
- [Bootstrap CLI](bootstrap-cli.md)
|
||||||
- [Manifeste `mcp.toml`](manifest.md)
|
- [Manifeste `mcp.toml`](manifest.md)
|
||||||
- [Génération depuis `mcp.toml`](generate.md)
|
|
||||||
- [Scaffolding](scaffolding.md)
|
- [Scaffolding](scaffolding.md)
|
||||||
- [Config JSON](config.md)
|
- [Config JSON](config.md)
|
||||||
- [Secrets](secrets.md)
|
- [Secrets](secrets.md)
|
||||||
- [Helpers CLI](cli-helpers.md)
|
- [Helpers CLI](cli-helpers.md)
|
||||||
- [Auto-update](auto-update.md)
|
- [Auto-update](auto-update.md)
|
||||||
- [Exemple minimal](minimal-example.md)
|
- [Exemple minimal](minimal-example.md)
|
||||||
- [Limites](limitations.md)
|
- [Limites actuelles](limitations.md)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Le parseur de release supporte :
|
||||||
- format `assets.links` (Gitea/GitLab)
|
- format `assets.links` (Gitea/GitLab)
|
||||||
- format `assets[]` avec `browser_download_url` (GitHub et Gitea API)
|
- format `assets[]` avec `browser_download_url` (GitHub et Gitea API)
|
||||||
|
|
||||||
Le format attendu pour la réponse `latest release` est :
|
Le format attendu pour la réponse `latest release` est actuellement :
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,8 @@
|
||||||
# Bootstrap CLI
|
# Bootstrap CLI
|
||||||
|
|
||||||
Le package `bootstrap` fournit un point d'entrée CLI uniforme pour les binaires
|
Le package `bootstrap` reste optionnel : une application peut l'utiliser pour uniformiser le parsing CLI et l'aide, sans imposer de runtime monolithique.
|
||||||
MCP. Il gère le parsing des arguments, l'aide, les alias et le routage vers les
|
|
||||||
hooks fournis par l'application.
|
|
||||||
|
|
||||||
## Commandes disponibles
|
Exemple minimal :
|
||||||
|
|
||||||
| Commande | Description |
|
|
||||||
|---|---|
|
|
||||||
| `setup` | Initialiser ou mettre à jour la configuration locale |
|
|
||||||
| `login` | Authentifier et déverrouiller Bitwarden pour persister `BW_SESSION` |
|
|
||||||
| `mcp` | Démarrer le serveur MCP |
|
|
||||||
| `config show` | Afficher la configuration résolue et la provenance des valeurs |
|
|
||||||
| `config test` | Vérifier la configuration et la connectivité |
|
|
||||||
| `config delete` | Supprimer un profil local |
|
|
||||||
| `update` | Auto-update du binaire |
|
|
||||||
| `version` | Afficher la version |
|
|
||||||
|
|
||||||
Les commandes sans hook correspondant sont automatiquement masquées de l'aide et
|
|
||||||
retournent une erreur `ErrUnknownCommand`. Exception : `version` affiche
|
|
||||||
`Options.Version` si fourni, sans hook.
|
|
||||||
|
|
||||||
## Utilisation
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -29,22 +10,23 @@ func main() {
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Description: "Client MCP",
|
Description: "Client MCP",
|
||||||
Version: version,
|
Version: version,
|
||||||
EnableDoctorAlias: true,
|
EnableDoctorAlias: true, // expose `doctor` comme alias de `config test`
|
||||||
|
AliasDescriptions: map[string]string{
|
||||||
|
"doctor": "Diagnostiquer la configuration locale.",
|
||||||
|
},
|
||||||
Hooks: bootstrap.Hooks{
|
Hooks: bootstrap.Hooks{
|
||||||
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
Setup: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runSetup(ctx, inv.Args)
|
return runSetup(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
Login: bootstrap.BitwardenLoginHandler("my-mcp"),
|
|
||||||
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runMCP(ctx, inv.Args)
|
return runMCP(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runConfigShow(ctx, inv.Args)
|
return runConfigShow(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
|
ConfigTest: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
OpenStore: openStore,
|
return runConfigTest(ctx, inv.Args)
|
||||||
ConnectivityCheck: connectivityCheck,
|
},
|
||||||
}),
|
|
||||||
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
|
Update: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runUpdate(ctx, inv.Args)
|
return runUpdate(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
|
|
@ -56,97 +38,8 @@ func main() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Handlers fournis
|
Si `Hooks.Version` n'est pas fourni, la sous-commande `version` affiche automatiquement `Options.Version`.
|
||||||
|
|
||||||
### `BitwardenLoginHandler`
|
Sans arguments, `bootstrap.Run` affiche l'aide globale (pas de lancement implicite de `mcp`).
|
||||||
|
La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`).
|
||||||
```go
|
Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale.
|
||||||
bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler
|
|
||||||
```
|
|
||||||
|
|
||||||
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`).
|
|
||||||
|
|
|
||||||
|
|
@ -126,11 +126,8 @@ if err := cli.RenderResolutionProvenance(os.Stdout, resolution); err != nil {
|
||||||
|
|
||||||
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
|
`ResolveOptions.Order` permet de changer la priorité globale si nécessaire, et `FieldSpec.Sources` permet de définir un ordre spécifique pour un champ.
|
||||||
|
|
||||||
Le package fournit un socle réutilisable pour une commande `doctor`.
|
Le package fournit aussi un socle réutilisable pour une commande `doctor`.
|
||||||
Pour les cas standards (config, secret store, connectivité), préférer
|
L'application cliente choisit comment exposer la commande (`flag`, `cobra`, etc.), mais peut réutiliser les checks communs et ajouter ses propres hooks :
|
||||||
`bootstrap.StandardConfigTestHandler` qui câble `RunDoctor` sans boilerplate.
|
|
||||||
|
|
||||||
Pour un contrôle fin ou un config test impératif, utiliser `RunDoctor` directement :
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||||
|
|
@ -140,19 +137,32 @@ report := cli.RunDoctor(context.Background(), cli.DoctorOptions{
|
||||||
ServiceName: "my-mcp",
|
ServiceName: "my-mcp",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
SecretBackendPolicy: secretstore.BackendBitwardenCLI,
|
||||||
|
BitwardenOptions: cli.BitwardenDoctorOptions{
|
||||||
|
LookupEnv: os.LookupEnv,
|
||||||
|
},
|
||||||
|
RequiredSecrets: []cli.DoctorSecret{
|
||||||
|
{Name: "api-token", Label: "API token"},
|
||||||
|
},
|
||||||
|
SecretStoreFactory: func() (secretstore.Store, error) {
|
||||||
|
return secretstore.OpenFromManifest(secretstore.OpenFromManifestOptions{
|
||||||
|
ServiceName: "my-mcp",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
ManifestDir: ".",
|
||||||
ConnectivityCheck: func(context.Context) cli.DoctorResult {
|
ConnectivityCheck: func(context.Context) cli.DoctorResult {
|
||||||
if err := pingBackend(); err != nil {
|
if err := pingBackend(); err != nil {
|
||||||
return cli.DoctorResult{
|
return cli.DoctorResult{
|
||||||
Name: "connectivity",
|
Name: "connectivity",
|
||||||
Status: cli.DoctorStatusFail,
|
Status: cli.DoctorStatusFail,
|
||||||
Summary: "backend inaccessible",
|
Summary: "backend is unreachable",
|
||||||
Detail: err.Error(),
|
Detail: err.Error(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cli.DoctorResult{
|
return cli.DoctorResult{
|
||||||
Name: "connectivity",
|
Name: "connectivity",
|
||||||
Status: cli.DoctorStatusOK,
|
Status: cli.DoctorStatusOK,
|
||||||
Summary: "backend accessible",
|
Summary: "backend is reachable",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -166,10 +176,6 @@ if report.HasFailures() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ManifestDir` est optionnel. Quand il est fourni, `RunDoctor` inclut un
|
|
||||||
`ManifestCheck` qui vérifie la présence et la validité de `mcp.toml` dans ce
|
|
||||||
répertoire. Ne l'inclure que si ce check est pertinent pour l'application.
|
|
||||||
|
|
||||||
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
|
Quand `SecretBackendPolicy` vaut `bitwarden-cli`, le check Bitwarden est ajouté automatiquement.
|
||||||
Pour le désactiver explicitement :
|
Pour le désactiver explicitement :
|
||||||
|
|
||||||
|
|
|
||||||
150
docs/generate.md
150
docs/generate.md
|
|
@ -1,150 +0,0 @@
|
||||||
# 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`.
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get forge.lclr.dev/AI/mcp-framework
|
go get gitea.lclr.dev/AI/mcp-framework
|
||||||
```
|
```
|
||||||
|
|
||||||
## CLI de scaffold
|
## CLI de scaffold
|
||||||
|
|
@ -11,7 +11,7 @@ go get forge.lclr.dev/AI/mcp-framework
|
||||||
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
|
Pour initialiser un projet MCP depuis un dossier vide, sans écrire de runner Go :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install forge.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
go install gitea.lclr.dev/AI/mcp-framework/cmd/mcp-framework@latest
|
||||||
mcp-framework scaffold init \
|
mcp-framework scaffold init \
|
||||||
--target ./my-mcp \
|
--target ./my-mcp \
|
||||||
--module example.com/my-mcp \
|
--module example.com/my-mcp \
|
||||||
|
|
@ -29,20 +29,12 @@ go run ./cmd/my-mcp help
|
||||||
|
|
||||||
## Utilisation type
|
## Utilisation type
|
||||||
|
|
||||||
Un flux complet côté application :
|
Le flux typique côté application est :
|
||||||
|
|
||||||
1. Déclarer `mcp.toml` à la racine du module.
|
1. Déclarer les sous-commandes communes via `bootstrap` (optionnel).
|
||||||
2. Lancer `mcp-framework generate` pour produire le package `mcpgen`.
|
2. Résoudre le profil actif avec `cli`.
|
||||||
3. Déclarer les sous-commandes communes via `bootstrap` si l'application utilise le bootstrap CLI.
|
3. Charger la config versionnée avec `config`.
|
||||||
4. Résoudre le profil actif avec `cli`.
|
4. Lire les secrets avec `secretstore`.
|
||||||
5. Charger la config versionnée avec `config`.
|
5. Charger le manifest runtime avec `manifest` (`mcp.toml` local, ou fallback embarqué).
|
||||||
6. Lire les secrets avec `secretstore` ou `mcpgen.OpenSecretStore`.
|
6. Exécuter l'auto-update avec `update` si nécessaire.
|
||||||
7. Charger le manifest runtime avec `mcpgen.LoadManifest`.
|
7. Exécuter `doctor` pour diagnostiquer la configuration locale et brancher des checks métier.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Limites
|
# Limites actuelles
|
||||||
|
|
||||||
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|
- l'auto-update reste volontairement simple et ne supporte pas encore de scripts externes
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ known = ["MY_MCP_PROFILE", "MY_MCP_URL", "MY_MCP_TOKEN"]
|
||||||
|
|
||||||
[secret_store]
|
[secret_store]
|
||||||
backend_policy = "auto"
|
backend_policy = "auto"
|
||||||
# Optionnel : mettre false pour désactiver le cache Bitwarden.
|
|
||||||
bitwarden_cache = true
|
|
||||||
|
|
||||||
[profiles]
|
[profiles]
|
||||||
default = "prod"
|
default = "prod"
|
||||||
|
|
@ -38,26 +36,6 @@ known = ["dev", "staging", "prod"]
|
||||||
|
|
||||||
[bootstrap]
|
[bootstrap]
|
||||||
description = "Client MCP interne"
|
description = "Client MCP interne"
|
||||||
|
|
||||||
[[config.fields]]
|
|
||||||
name = "base_url"
|
|
||||||
flag = "base-url"
|
|
||||||
env = "MY_MCP_URL"
|
|
||||||
config_key = "base_url"
|
|
||||||
type = "url"
|
|
||||||
label = "Base URL"
|
|
||||||
required = true
|
|
||||||
sources = ["flag", "env", "config"]
|
|
||||||
|
|
||||||
[[config.fields]]
|
|
||||||
name = "api_token"
|
|
||||||
flag = "api-token"
|
|
||||||
env = "MY_MCP_TOKEN"
|
|
||||||
secret_key_template = "profile/{profile}/api-token"
|
|
||||||
type = "secret"
|
|
||||||
label = "API token"
|
|
||||||
required = true
|
|
||||||
sources = ["flag", "env", "secret"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Champs supportés :
|
Champs supportés :
|
||||||
|
|
@ -82,25 +60,9 @@ Champs supportés :
|
||||||
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
||||||
- `[environment].known` : variables d'environnement connues du projet.
|
- `[environment].known` : variables d'environnement connues du projet.
|
||||||
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`).
|
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`).
|
||||||
- `[secret_store].bitwarden_cache` : active le cache Bitwarden mémoire et disque chiffré quand `backend_policy = "bitwarden-cli"`. Par défaut, le cache est activé si le champ est absent. Mettre `false` pour le désactiver.
|
|
||||||
- `[profiles].default` : profil recommandé par défaut.
|
- `[profiles].default` : profil recommandé par défaut.
|
||||||
- `[profiles].known` : profils connus du projet.
|
- `[profiles].known` : profils connus du projet.
|
||||||
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
||||||
- `[[config.fields]]` : champs de configuration déclaratifs consommés par
|
|
||||||
`mcp-framework generate`.
|
|
||||||
- `name` : identifiant stable du champ.
|
|
||||||
- `flag` : nom du flag CLI, sans `--`.
|
|
||||||
- `env` : variable d'environnement associée.
|
|
||||||
- `config_key` : clé dans la config fichier du projet.
|
|
||||||
- `secret_key_template` : clé de secret, avec `{profile}` remplacé par le
|
|
||||||
profil courant dans le code généré.
|
|
||||||
- `type` : type de setup (`string`, `url`, `secret`, `bool`, `list`).
|
|
||||||
- `label` : libellé humain utilisé pendant le setup.
|
|
||||||
- `default` : valeur par défaut optionnelle.
|
|
||||||
- `required` : si `true`, la résolution échoue quand aucune source ne fournit
|
|
||||||
de valeur.
|
|
||||||
- `sources` : ordre de résolution spécifique au champ (`flag`, `env`,
|
|
||||||
`config`, `secret`).
|
|
||||||
|
|
||||||
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.
|
Toutes ces sections (hors `[update]` selon les besoins de l'application) sont optionnelles.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,38 @@
|
||||||
# Exemple minimal
|
# Exemple minimal
|
||||||
|
|
||||||
Cet exemple suppose qu'un `mcp.toml` existe à la racine du module et que le
|
|
||||||
package généré est à jour :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mcp-framework generate
|
|
||||||
```
|
|
||||||
|
|
||||||
Exemple de runner Go :
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"example.com/my-mcp/mcpgen"
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/config"
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/update"
|
|
||||||
)
|
|
||||||
|
|
||||||
var version = "dev"
|
|
||||||
|
|
||||||
type Profile struct {
|
type Profile struct {
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
var embeddedManifest = `...` // fallback utilisé si aucun mcp.toml runtime n'est trouvé
|
||||||
if err := run(context.Background()); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(ctx context.Context) error {
|
func run(ctx context.Context, flagProfile string) error {
|
||||||
cfgStore := config.NewStore[Profile](mcpgen.BinaryName)
|
cfgStore := config.NewStore[Profile]("my-mcp")
|
||||||
|
|
||||||
cfg, _, err := cfgStore.LoadDefault()
|
cfg, _, err := cfgStore.LoadDefault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, source, err := mcpgen.LoadManifest(".")
|
profileName := cli.ResolveProfileName(flagProfile, os.Getenv("MY_MCP_PROFILE"), cfg.CurrentProfile)
|
||||||
|
profile := cfg.Profiles[profileName]
|
||||||
|
|
||||||
|
manifestFile, _, err := manifest.LoadDefaultOrEmbedded(".", embeddedManifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("manifest:", source)
|
|
||||||
|
|
||||||
updateOptions, err := mcpgen.UpdateOptions(version, os.Stdout)
|
err = update.Run(ctx, update.Options{
|
||||||
|
CurrentVersion: version,
|
||||||
|
BinaryName: "my-mcp",
|
||||||
|
ReleaseSource: manifestFile.Update.ReleaseSource(),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := update.Run(ctx, updateOptions); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = cfg
|
_ = profile
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
# Packages
|
# Packages
|
||||||
|
|
||||||
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `login`, `mcp`, `config show|test`, `update`, `version`) et hooks métier explicites.
|
- `bootstrap` : couche CLI optionnelle avec sous-commandes communes (`setup`, `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`.
|
- `cli` : helpers pour résoudre un profil, valider une URL, demander des valeurs à l'utilisateur et exécuter un `doctor`.
|
||||||
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
- `config` : lecture/écriture atomique d'une config JSON versionnée dans `os.UserConfigDir()`.
|
||||||
- `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
|
- `manifest` : lecture de `mcp.toml` à la racine du projet, fallback embarqué pour le runtime, conversion vers `update.ReleaseSource` et exposition de métadonnées pour `bootstrap`/scaffolding.
|
||||||
- `generate` : génération de code Go depuis `mcp.toml` (`mcpgen/manifest.go`, metadata, update, secret store, config fields).
|
|
||||||
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage).
|
- `scaffold` : génération d'un squelette de projet MCP (arborescence, `main.go`, `mcp.toml`, `install.sh` wizard, wiring de base et README de démarrage).
|
||||||
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`.
|
- `secretstore` : lecture/écriture de secrets dans le wallet natif, avec helpers runtime `OpenFromManifest`, `DescribeRuntime`, `PreflightFromManifest` et formatage homogène via `FormatBackendStatus`.
|
||||||
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
- `update` : téléchargement et remplacement du binaire courant depuis un endpoint de release.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP :
|
Le package `scaffold` génère un point de départ cohérent pour un nouveau binaire MCP :
|
||||||
|
|
||||||
- arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
|
- arborescence recommandée (`cmd/<binary>/main.go`, `internal/app/app.go`, `mcp.toml`)
|
||||||
- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI, setup local et export JSON MCP
|
- script `install.sh` prêt à publier (`curl .../install.sh | bash`) avec wizard TUI (setup, apply Claude/Codex, JSON MCP)
|
||||||
- wiring initial `bootstrap + config + secretstore + update`
|
- wiring initial `bootstrap + config + secretstore + update`
|
||||||
- `README.md` de démarrage
|
- `README.md` de démarrage
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ Exemple :
|
||||||
```go
|
```go
|
||||||
result, err := scaffold.Generate(scaffold.Options{
|
result, err := scaffold.Generate(scaffold.Options{
|
||||||
TargetDir: "./my-mcp",
|
TargetDir: "./my-mcp",
|
||||||
ModulePath: "forge.lclr.dev/AI/my-mcp",
|
ModulePath: "gitea.lclr.dev/AI/my-mcp",
|
||||||
BinaryName: "my-mcp",
|
BinaryName: "my-mcp",
|
||||||
Description: "Client MCP interne",
|
Description: "Client MCP interne",
|
||||||
DefaultProfile: "prod",
|
DefaultProfile: "prod",
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ Pour imposer KWallet sur Linux :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
store, err := secretstore.Open(secretstore.Options{
|
store, err := secretstore.Open(secretstore.Options{
|
||||||
ServiceName: "my-mcp",
|
ServiceName: "email-mcp",
|
||||||
BackendPolicy: secretstore.BackendKWalletOnly,
|
BackendPolicy: secretstore.BackendKWalletOnly,
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
@ -65,41 +65,13 @@ Pour imposer Bitwarden via son CLI :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
store, err := secretstore.Open(secretstore.Options{
|
store, err := secretstore.Open(secretstore.Options{
|
||||||
ServiceName: "my-mcp",
|
ServiceName: "email-mcp",
|
||||||
BackendPolicy: secretstore.BackendBitwardenCLI,
|
BackendPolicy: secretstore.BackendBitwardenCLI,
|
||||||
// Optionnel si `bw` n'est pas dans le PATH :
|
// Optionnel si `bw` n'est pas dans le PATH :
|
||||||
// BitwardenCommand: "/usr/local/bin/bw",
|
// BitwardenCommand: "/usr/local/bin/bw",
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cache Bitwarden
|
|
||||||
|
|
||||||
Le backend `bitwarden-cli` met en cache les lectures de secrets par défaut.
|
|
||||||
Le cache mémoire évite les appels répétés au CLI dans un même process. Le cache
|
|
||||||
disque est chiffré avec une clé dérivée de `BW_SESSION` via HKDF-SHA256 et
|
|
||||||
AES-GCM.
|
|
||||||
|
|
||||||
TTL par défaut : 10 minutes.
|
|
||||||
|
|
||||||
Pour désactiver le cache dans `mcp.toml` :
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[secret_store]
|
|
||||||
backend_policy = "bitwarden-cli"
|
|
||||||
bitwarden_cache = false
|
|
||||||
```
|
|
||||||
|
|
||||||
Pour le désactiver sans modifier le manifeste :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MCP_FRAMEWORK_BITWARDEN_CACHE=0
|
|
||||||
```
|
|
||||||
|
|
||||||
Le fichier de cache et le binaire installé ne suffisent pas à déchiffrer les
|
|
||||||
secrets. Si `BW_SESSION` change ou disparaît, les entrées disque existantes
|
|
||||||
deviennent inutilisables. Cette protection ne couvre pas un attaquant qui peut
|
|
||||||
lire l'environnement ou la mémoire du process pendant l'exécution.
|
|
||||||
|
|
||||||
Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) :
|
Pour vérifier explicitement que Bitwarden est prêt (login + unlock + `BW_SESSION`) :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -119,34 +91,6 @@ if err := secretstore.EnsureBitwardenReady(secretstore.Options{
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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 :
|
Pour stocker un secret structuré en JSON :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -187,8 +131,7 @@ effective := secretstore.EffectiveBackendPolicy(store)
|
||||||
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any...
|
fmt.Println("backend effectif:", effective) // bitwarden-cli, env-only, keyring-any...
|
||||||
```
|
```
|
||||||
|
|
||||||
Pour obtenir en un seul appel une description runtime légère (source manifeste,
|
Pour obtenir en un seul appel une description runtime (source manifeste, policy déclarée/effective, disponibilité) :
|
||||||
policy déclarée/effective, backend affiché) :
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
|
desc, err := secretstore.DescribeRuntime(secretstore.DescribeRuntimeOptions{
|
||||||
|
|
@ -203,8 +146,7 @@ fmt.Println(secretstore.FormatBackendStatus(desc))
|
||||||
// declared=... effective=... display=... ready=... source=...
|
// declared=... effective=... display=... ready=... source=...
|
||||||
```
|
```
|
||||||
|
|
||||||
`DescribeRuntime` ne contacte pas Bitwarden par défaut. Pour vérifier réellement
|
Pour un préflight réutilisable dans `setup`, `config show` et `config test` :
|
||||||
la disponibilité du backend, utiliser le préflight :
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{
|
report, err := secretstore.PreflightFromManifest(secretstore.PreflightOptions{
|
||||||
|
|
@ -222,17 +164,13 @@ fmt.Println(report.Remediation) // action recommandée
|
||||||
|
|
||||||
## Debug Bitwarden en 60 secondes
|
## 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 :
|
1. Vérifier l'état de session :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bw status
|
bw status
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Déverrouiller le vault et exporter `BW_SESSION` (ou utiliser `LoginBitwarden`) :
|
2. Déverrouiller le vault et exporter `BW_SESSION` :
|
||||||
|
|
||||||
- Bash/Zsh :
|
- Bash/Zsh :
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,217 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,617 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,499 +0,0 @@
|
||||||
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 forge.lclr.dev/AI/mcp-framework
|
module gitea.lclr.dev/AI/mcp-framework
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/update"
|
"gitea.lclr.dev/AI/mcp-framework/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultFile = "mcp.toml"
|
const DefaultFile = "mcp.toml"
|
||||||
|
|
@ -23,7 +23,6 @@ type File struct {
|
||||||
SecretStore SecretStore `toml:"secret_store"`
|
SecretStore SecretStore `toml:"secret_store"`
|
||||||
Profiles Profiles `toml:"profiles"`
|
Profiles Profiles `toml:"profiles"`
|
||||||
Bootstrap Bootstrap `toml:"bootstrap"`
|
Bootstrap Bootstrap `toml:"bootstrap"`
|
||||||
Config Config `toml:"config"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Update struct {
|
type Update struct {
|
||||||
|
|
@ -49,8 +48,7 @@ type Environment struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecretStore struct {
|
type SecretStore struct {
|
||||||
BackendPolicy string `toml:"backend_policy"`
|
BackendPolicy string `toml:"backend_policy"`
|
||||||
BitwardenCache *bool `toml:"bitwarden_cache"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Profiles struct {
|
type Profiles struct {
|
||||||
|
|
@ -62,23 +60,6 @@ type Bootstrap struct {
|
||||||
Description string `toml:"description"`
|
Description string `toml:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Fields []ConfigField `toml:"fields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConfigField struct {
|
|
||||||
Name string `toml:"name"`
|
|
||||||
Flag string `toml:"flag"`
|
|
||||||
Env string `toml:"env"`
|
|
||||||
ConfigKey string `toml:"config_key"`
|
|
||||||
SecretKeyTemplate string `toml:"secret_key_template"`
|
|
||||||
Type string `toml:"type"`
|
|
||||||
Label string `toml:"label"`
|
|
||||||
Default string `toml:"default"`
|
|
||||||
Required bool `toml:"required"`
|
|
||||||
Sources []string `toml:"sources"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BootstrapMetadata struct {
|
type BootstrapMetadata struct {
|
||||||
BinaryName string
|
BinaryName string
|
||||||
Description string
|
Description string
|
||||||
|
|
@ -199,7 +180,6 @@ func (f *File) normalize() {
|
||||||
f.SecretStore.normalize()
|
f.SecretStore.normalize()
|
||||||
f.Profiles.normalize()
|
f.Profiles.normalize()
|
||||||
f.Bootstrap.normalize()
|
f.Bootstrap.normalize()
|
||||||
f.Config.normalize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Update) normalize() {
|
func (u *Update) normalize() {
|
||||||
|
|
@ -235,24 +215,6 @@ func (b *Bootstrap) normalize() {
|
||||||
b.Description = strings.TrimSpace(b.Description)
|
b.Description = strings.TrimSpace(b.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) normalize() {
|
|
||||||
for i := range c.Fields {
|
|
||||||
c.Fields[i].normalize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *ConfigField) normalize() {
|
|
||||||
f.Name = strings.TrimSpace(f.Name)
|
|
||||||
f.Flag = strings.TrimSpace(f.Flag)
|
|
||||||
f.Env = strings.TrimSpace(f.Env)
|
|
||||||
f.ConfigKey = strings.TrimSpace(f.ConfigKey)
|
|
||||||
f.SecretKeyTemplate = strings.TrimSpace(f.SecretKeyTemplate)
|
|
||||||
f.Type = strings.ToLower(strings.TrimSpace(f.Type))
|
|
||||||
f.Label = strings.TrimSpace(f.Label)
|
|
||||||
f.Default = strings.TrimSpace(f.Default)
|
|
||||||
f.Sources = normalizeStringList(f.Sources)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u Update) ReleaseSource() update.ReleaseSource {
|
func (u Update) ReleaseSource() update.ReleaseSource {
|
||||||
u.normalize()
|
u.normalize()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -235,127 +235,6 @@ description = " Client MCP interne "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadParsesSecretStoreBitwardenCache(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, DefaultFile)
|
|
||||||
|
|
||||||
const content = `
|
|
||||||
[secret_store]
|
|
||||||
backend_policy = "bitwarden-cli"
|
|
||||||
bitwarden_cache = false
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile manifest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned error: %v", err)
|
|
||||||
}
|
|
||||||
if file.SecretStore.BitwardenCache == nil {
|
|
||||||
t.Fatal("bitwarden cache option is nil, want explicit false pointer")
|
|
||||||
}
|
|
||||||
if *file.SecretStore.BitwardenCache {
|
|
||||||
t.Fatal("bitwarden cache option = true, want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadLeavesOmittedBitwardenCacheUnset(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, DefaultFile)
|
|
||||||
|
|
||||||
const content = `
|
|
||||||
[secret_store]
|
|
||||||
backend_policy = "bitwarden-cli"
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile manifest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned error: %v", err)
|
|
||||||
}
|
|
||||||
if file.SecretStore.BitwardenCache != nil {
|
|
||||||
t.Fatalf("bitwarden cache option = %v, want nil when omitted", *file.SecretStore.BitwardenCache)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadParsesConfigFields(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, DefaultFile)
|
|
||||||
|
|
||||||
const content = `
|
|
||||||
[[config.fields]]
|
|
||||||
name = " base_url "
|
|
||||||
flag = "base-url"
|
|
||||||
env = "BASE_URL"
|
|
||||||
config_key = "base_url"
|
|
||||||
type = " url "
|
|
||||||
label = " Graylog URL "
|
|
||||||
required = true
|
|
||||||
sources = [" flag ", "env", "config"]
|
|
||||||
|
|
||||||
[[config.fields]]
|
|
||||||
name = "api_token"
|
|
||||||
flag = "api-token"
|
|
||||||
env = "API_TOKEN"
|
|
||||||
secret_key_template = "profile/{profile}/api-token"
|
|
||||||
type = "secret"
|
|
||||||
required = true
|
|
||||||
sources = ["flag", "env", "secret"]
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
||||||
t.Fatalf("WriteFile manifest: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := Load(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(file.Config.Fields) != 2 {
|
|
||||||
t.Fatalf("config fields = %d, want 2", len(file.Config.Fields))
|
|
||||||
}
|
|
||||||
|
|
||||||
baseURL := file.Config.Fields[0]
|
|
||||||
if baseURL.Name != "base_url" {
|
|
||||||
t.Fatalf("base URL name = %q", baseURL.Name)
|
|
||||||
}
|
|
||||||
if baseURL.Flag != "base-url" {
|
|
||||||
t.Fatalf("base URL flag = %q", baseURL.Flag)
|
|
||||||
}
|
|
||||||
if baseURL.Env != "BASE_URL" {
|
|
||||||
t.Fatalf("base URL env = %q", baseURL.Env)
|
|
||||||
}
|
|
||||||
if baseURL.ConfigKey != "base_url" {
|
|
||||||
t.Fatalf("base URL config key = %q", baseURL.ConfigKey)
|
|
||||||
}
|
|
||||||
if baseURL.Type != "url" {
|
|
||||||
t.Fatalf("base URL type = %q", baseURL.Type)
|
|
||||||
}
|
|
||||||
if baseURL.Label != "Graylog URL" {
|
|
||||||
t.Fatalf("base URL label = %q", baseURL.Label)
|
|
||||||
}
|
|
||||||
if !baseURL.Required {
|
|
||||||
t.Fatal("base URL should be required")
|
|
||||||
}
|
|
||||||
if !slices.Equal(baseURL.Sources, []string{"flag", "env", "config"}) {
|
|
||||||
t.Fatalf("base URL sources = %v", baseURL.Sources)
|
|
||||||
}
|
|
||||||
|
|
||||||
token := file.Config.Fields[1]
|
|
||||||
if token.SecretKeyTemplate != "profile/{profile}/api-token" {
|
|
||||||
t.Fatalf("token secret key template = %q", token.SecretKeyTemplate)
|
|
||||||
}
|
|
||||||
if !slices.Equal(token.Sources, []string{"flag", "env", "secret"}) {
|
|
||||||
t.Fatalf("token sources = %v", token.Sources)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadEmbeddedParsesContent(t *testing.T) {
|
func TestLoadEmbeddedParsesContent(t *testing.T) {
|
||||||
file, source, err := LoadEmbedded(`
|
file, source, err := LoadEmbedded(`
|
||||||
[update]
|
[update]
|
||||||
|
|
|
||||||
|
|
@ -1537,14 +1537,13 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/bootstrap"
|
"gitea.lclr.dev/AI/mcp-framework/bootstrap"
|
||||||
"forge.lclr.dev/AI/mcp-framework/cli"
|
"gitea.lclr.dev/AI/mcp-framework/cli"
|
||||||
"forge.lclr.dev/AI/mcp-framework/config"
|
"gitea.lclr.dev/AI/mcp-framework/config"
|
||||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
"gitea.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
"forge.lclr.dev/AI/mcp-framework/update"
|
"gitea.lclr.dev/AI/mcp-framework/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}"
|
var embeddedManifest = ` + "`" + `binary_name = "{{.BinaryName}}"
|
||||||
|
|
@ -1596,11 +1595,6 @@ type Runtime struct {
|
||||||
SecretName string
|
SecretName string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
ansiRedColor = "\033[31m"
|
|
||||||
ansiResetColor = "\033[0m"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Run(ctx context.Context, args []string, version string) error {
|
func Run(ctx context.Context, args []string, version string) error {
|
||||||
runtime, err := NewRuntime(version)
|
runtime, err := NewRuntime(version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1664,7 +1658,6 @@ func (r Runtime) Run(ctx context.Context, args []string) error {
|
||||||
Args: args,
|
Args: args,
|
||||||
Hooks: bootstrap.Hooks{
|
Hooks: bootstrap.Hooks{
|
||||||
Setup: r.runSetup,
|
Setup: r.runSetup,
|
||||||
Login: r.runLogin,
|
|
||||||
MCP: r.runMCP,
|
MCP: r.runMCP,
|
||||||
ConfigShow: r.runConfigShow,
|
ConfigShow: r.runConfigShow,
|
||||||
ConfigTest: r.runConfigTest,
|
ConfigTest: r.runConfigTest,
|
||||||
|
|
@ -1757,42 +1750,6 @@ func (r Runtime) runSetup(_ context.Context, inv bootstrap.Invocation) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runtime) runLogin(_ context.Context, inv bootstrap.Invocation) error {
|
|
||||||
if r.activeBackendPolicy() != secretstore.BackendBitwardenCLI {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"commande login disponible uniquement avec secret_store.backend_policy=%q",
|
|
||||||
secretstore.BackendBitwardenCLI,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
stdin := inv.Stdin
|
|
||||||
if stdin == nil {
|
|
||||||
stdin = os.Stdin
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout := inv.Stdout
|
|
||||||
if stdout == nil {
|
|
||||||
stdout = os.Stdout
|
|
||||||
}
|
|
||||||
|
|
||||||
stderr := inv.Stderr
|
|
||||||
if stderr == nil {
|
|
||||||
stderr = os.Stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
|
|
||||||
ServiceName: r.BinaryName,
|
|
||||||
Stdin: stdin,
|
|
||||||
Stdout: stdout,
|
|
||||||
Stderr: stderr,
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fmt.Fprintf(stdout, "Session Bitwarden persistée pour %q.\n", r.BinaryName)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error {
|
func (r Runtime) runMCP(_ context.Context, inv bootstrap.Invocation) error {
|
||||||
stdout := inv.Stdout
|
stdout := inv.Stdout
|
||||||
if stdout == nil {
|
if stdout == nil {
|
||||||
|
|
@ -1859,16 +1816,14 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er
|
||||||
stdout = os.Stdout
|
stdout = os.Stdout
|
||||||
}
|
}
|
||||||
|
|
||||||
secretStoreFactory := memoizeSecretStoreFactory(r.openSecretStore)
|
|
||||||
|
|
||||||
report := cli.RunDoctor(ctx, cli.DoctorOptions{
|
report := cli.RunDoctor(ctx, cli.DoctorOptions{
|
||||||
ConfigCheck: cli.NewConfigCheck(r.ConfigStore),
|
ConfigCheck: cli.NewConfigCheck(r.ConfigStore),
|
||||||
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(secretStoreFactory),
|
SecretStoreCheck: cli.SecretStoreAvailabilityCheck(r.openSecretStore),
|
||||||
SecretBackendPolicy: r.activeBackendPolicy(),
|
SecretBackendPolicy: r.activeBackendPolicy(),
|
||||||
RequiredSecrets: []cli.DoctorSecret{
|
RequiredSecrets: []cli.DoctorSecret{
|
||||||
{Name: r.SecretName, Label: "API token"},
|
{Name: r.SecretName, Label: "API token"},
|
||||||
},
|
},
|
||||||
SecretStoreFactory: secretStoreFactory,
|
SecretStoreFactory: r.openSecretStore,
|
||||||
ManifestCheck: r.manifestDoctorCheck(),
|
ManifestCheck: r.manifestDoctorCheck(),
|
||||||
BitwardenOptions: cli.BitwardenDoctorOptions{
|
BitwardenOptions: cli.BitwardenDoctorOptions{
|
||||||
LookupEnv: os.LookupEnv,
|
LookupEnv: os.LookupEnv,
|
||||||
|
|
@ -1886,25 +1841,6 @@ func (r Runtime) runConfigTest(ctx context.Context, inv bootstrap.Invocation) er
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func memoizeSecretStoreFactory(factory func() (secretstore.Store, error)) func() (secretstore.Store, error) {
|
|
||||||
if factory == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
once sync.Once
|
|
||||||
store secretstore.Store
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
return func() (secretstore.Store, error) {
|
|
||||||
once.Do(func() {
|
|
||||||
store, err = factory()
|
|
||||||
})
|
|
||||||
return store, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error {
|
func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
stdout := inv.Stdout
|
stdout := inv.Stdout
|
||||||
if stdout == nil {
|
if stdout == nil {
|
||||||
|
|
@ -1920,16 +1856,9 @@ func (r Runtime) runUpdate(ctx context.Context, inv bootstrap.Invocation) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runtime) openSecretStore() (secretstore.Store, error) {
|
func (r Runtime) openSecretStore() (secretstore.Store, error) {
|
||||||
policy := r.activeBackendPolicy()
|
|
||||||
if policy == secretstore.BackendBitwardenCLI {
|
|
||||||
if err := r.ensureBitwardenSession(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return secretstore.Open(secretstore.Options{
|
return secretstore.Open(secretstore.Options{
|
||||||
ServiceName: r.BinaryName,
|
ServiceName: r.BinaryName,
|
||||||
BackendPolicy: policy,
|
BackendPolicy: r.activeBackendPolicy(),
|
||||||
LookupEnv: func(name string) (string, bool) {
|
LookupEnv: func(name string) (string, bool) {
|
||||||
if name == r.SecretName {
|
if name == r.SecretName {
|
||||||
return os.LookupEnv(r.TokenEnv)
|
return os.LookupEnv(r.TokenEnv)
|
||||||
|
|
@ -1939,37 +1868,6 @@ func (r Runtime) openSecretStore() (secretstore.Store, error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Runtime) ensureBitwardenSession() error {
|
|
||||||
if hasBitwardenSessionInEnv() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
loaded, err := secretstore.EnsureBitwardenSessionEnv(secretstore.BitwardenSessionOptions{
|
|
||||||
ServiceName: r.BinaryName,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if loaded || hasBitwardenSessionInEnv() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.New(colorizeRed(fmt.Sprintf(
|
|
||||||
"Session Bitwarden introuvable. Lance %s login puis relance la commande.",
|
|
||||||
r.BinaryName,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasBitwardenSessionInEnv() bool {
|
|
||||||
session, ok := os.LookupEnv("BW_SESSION")
|
|
||||||
return ok && strings.TrimSpace(session) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func colorizeRed(message string) string {
|
|
||||||
return ansiRedColor + strings.TrimSpace(message) + ansiResetColor
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy {
|
func (r Runtime) activeBackendPolicy() secretstore.BackendPolicy {
|
||||||
policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy))
|
policy := secretstore.BackendPolicy(strings.TrimSpace(r.Manifest.SecretStore.BackendPolicy))
|
||||||
if policy == "" {
|
if policy == "" {
|
||||||
|
|
|
||||||
|
|
@ -67,10 +67,6 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
||||||
for _, snippet := range []string{
|
for _, snippet := range []string{
|
||||||
"config.NewStore[Profile]",
|
"config.NewStore[Profile]",
|
||||||
"secretstore.Open(secretstore.Options",
|
"secretstore.Open(secretstore.Options",
|
||||||
"secretstore.EnsureBitwardenSessionEnv",
|
|
||||||
"func (r Runtime) ensureBitwardenSession() error {",
|
|
||||||
"\\033[31m",
|
|
||||||
"secretstore.LoginBitwarden",
|
|
||||||
"update.Run",
|
"update.Run",
|
||||||
"manifest.LoadDefaultOrEmbedded",
|
"manifest.LoadDefaultOrEmbedded",
|
||||||
"bootstrap.Run",
|
"bootstrap.Run",
|
||||||
|
|
@ -80,10 +76,6 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
|
||||||
"ManifestSource",
|
"ManifestSource",
|
||||||
"ManifestCheck: r.manifestDoctorCheck()",
|
"ManifestCheck: r.manifestDoctorCheck()",
|
||||||
"SecretBackendPolicy: r.activeBackendPolicy()",
|
"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",
|
"cli.WriteSetupSecretVerified",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(string(appGo), snippet) {
|
if !strings.Contains(string(appGo), snippet) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -18,7 +17,6 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultBitwardenCommand = "bw"
|
defaultBitwardenCommand = "bw"
|
||||||
bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG"
|
|
||||||
bitwardenSessionEnvName = "BW_SESSION"
|
bitwardenSessionEnvName = "BW_SESSION"
|
||||||
bitwardenSecretFieldName = "mcp-secret"
|
bitwardenSecretFieldName = "mcp-secret"
|
||||||
bitwardenServiceFieldName = "mcp-service"
|
bitwardenServiceFieldName = "mcp-service"
|
||||||
|
|
@ -33,26 +31,13 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error)
|
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 runBitwardenCLI bitwardenRunner = executeBitwardenCLI
|
||||||
var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive
|
|
||||||
var startBitwardenLoaderFunc = startBitwardenLoader
|
|
||||||
var bitwardenLoaderActive atomic.Bool
|
var bitwardenLoaderActive atomic.Bool
|
||||||
var bitwardenDebugOutput io.Writer = os.Stderr
|
|
||||||
|
|
||||||
type bitwardenStore struct {
|
type bitwardenStore struct {
|
||||||
command string
|
command string
|
||||||
serviceName string
|
serviceName string
|
||||||
debug bool
|
|
||||||
lookupEnv func(string) (string, bool)
|
|
||||||
shell string
|
|
||||||
cache *bitwardenCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type bitwardenListItem struct {
|
type bitwardenListItem struct {
|
||||||
|
|
@ -69,71 +54,44 @@ func newBitwardenStore(options Options, policy BackendPolicy, serviceName string
|
||||||
if command == "" {
|
if command == "" {
|
||||||
command = defaultBitwardenCommand
|
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{
|
store := &bitwardenStore{
|
||||||
command: command,
|
command: command,
|
||||||
serviceName: serviceName,
|
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
|
if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != 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) {
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
return fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"requires bitwarden CLI command %q in PATH: %w",
|
"secret backend policy %q requires bitwarden CLI command %q in PATH: %w",
|
||||||
|
policy,
|
||||||
command,
|
command,
|
||||||
errors.Join(ErrBackendUnavailable, err),
|
ErrBackendUnavailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"cannot verify bitwarden CLI command %q: %w",
|
"secret backend policy %q cannot verify bitwarden CLI command %q: %w",
|
||||||
|
policy,
|
||||||
command,
|
command,
|
||||||
errors.Join(ErrBackendUnavailable, err),
|
errors.Join(ErrBackendUnavailable, err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := EnsureBitwardenReady(options); err != nil {
|
if err := EnsureBitwardenReady(Options{
|
||||||
return fmt.Errorf(
|
BitwardenCommand: command,
|
||||||
"cannot use bitwarden CLI command %q right now: %w",
|
LookupEnv: options.LookupEnv,
|
||||||
|
Shell: options.Shell,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"secret backend policy %q cannot use bitwarden CLI command %q right now: %w",
|
||||||
|
policy,
|
||||||
command,
|
command,
|
||||||
errors.Join(ErrBackendUnavailable, err),
|
errors.Join(ErrBackendUnavailable, err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func EnsureBitwardenReady(options Options) error {
|
func EnsureBitwardenReady(options Options) error {
|
||||||
|
|
@ -141,15 +99,29 @@ func EnsureBitwardenReady(options Options) error {
|
||||||
if command == "" {
|
if command == "" {
|
||||||
command = defaultBitwardenCommand
|
command = defaultBitwardenCommand
|
||||||
}
|
}
|
||||||
debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug)
|
|
||||||
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
unlockCommand := bitwardenUnlockRemediation(command, options.Shell)
|
||||||
|
|
||||||
status, err := readBitwardenStatus(command, debugEnabled)
|
lookupEnv := options.LookupEnv
|
||||||
if err != nil {
|
if lookupEnv == nil {
|
||||||
return err
|
lookupEnv = os.LookupEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
switch status {
|
output, err := runBitwardenCLI(command, 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.TrimSpace(status.Status)) {
|
||||||
case "unauthenticated":
|
case "unauthenticated":
|
||||||
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
|
||||||
case "locked":
|
case "locked":
|
||||||
|
|
@ -159,15 +131,7 @@ func EnsureBitwardenReady(options Options) error {
|
||||||
unlockCommand,
|
unlockCommand,
|
||||||
)
|
)
|
||||||
case "unlocked":
|
case "unlocked":
|
||||||
lookupEnv := options.LookupEnv
|
|
||||||
if lookupEnv == nil {
|
|
||||||
lookupEnv = os.LookupEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
session, ok := lookupEnv(bitwardenSessionEnvName)
|
session, ok := lookupEnv(bitwardenSessionEnvName)
|
||||||
if !ok || strings.TrimSpace(session) == "" {
|
|
||||||
session, ok = os.LookupEnv(bitwardenSessionEnvName)
|
|
||||||
}
|
|
||||||
if !ok || strings.TrimSpace(session) == "" {
|
if !ok || strings.TrimSpace(session) == "" {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: environment variable %q is missing; run `%s` then retry",
|
"%w: environment variable %q is missing; run `%s` then retry",
|
||||||
|
|
@ -181,30 +145,11 @@ func EnsureBitwardenReady(options Options) error {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: unsupported bitwarden status %q",
|
"%w: unsupported bitwarden status %q",
|
||||||
ErrBWUnavailable,
|
ErrBWUnavailable,
|
||||||
status,
|
strings.TrimSpace(status.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 {
|
func bitwardenUnlockRemediation(command, shellHint string) string {
|
||||||
unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command))
|
unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command))
|
||||||
|
|
||||||
|
|
@ -258,11 +203,7 @@ func detectShellFlavor(shellHint string) string {
|
||||||
|
|
||||||
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
||||||
secretName := s.scopedName(name)
|
secretName := s.scopedName(name)
|
||||||
if err := s.ensureReady(); err != nil {
|
item, err := s.findItem(secretName, name)
|
||||||
return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
item, payload, err := s.findItem(secretName, name)
|
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrNotFound):
|
case errors.Is(err, ErrNotFound):
|
||||||
template, err := s.itemTemplate()
|
template, err := s.itemTemplate()
|
||||||
|
|
@ -285,15 +226,16 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s.cache != nil {
|
|
||||||
s.cache.invalidate(name, secretName)
|
|
||||||
s.cache.store(name, secretName, secret)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
payload, err := s.itemByID(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret)
|
setBitwardenSecretPayload(payload, s.serviceName, name, secretName, label, secret)
|
||||||
encoded, err := s.encodePayload(payload)
|
encoded, err := s.encodePayload(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -311,26 +253,17 @@ func (s *bitwardenStore) SetSecret(name, label, secret string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cache != nil {
|
|
||||||
s.cache.invalidate(name, secretName)
|
|
||||||
s.cache.store(name, secretName, secret)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
||||||
secretName := s.scopedName(name)
|
secretName := s.scopedName(name)
|
||||||
if s.cache != nil {
|
item, err := s.findItem(secretName, name)
|
||||||
if secret, ok := s.cache.load(name, secretName); ok {
|
if err != nil {
|
||||||
return secret, nil
|
return "", err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.ensureReady(); err != nil {
|
payload, err := s.itemByID(item.ID)
|
||||||
return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, payload, err := s.findItem(secretName, name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -340,23 +273,13 @@ func (s *bitwardenStore) GetSecret(name string) (string, error) {
|
||||||
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
|
return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cache != nil {
|
|
||||||
s.cache.store(name, secretName, secret)
|
|
||||||
}
|
|
||||||
return secret, nil
|
return secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *bitwardenStore) DeleteSecret(name string) error {
|
func (s *bitwardenStore) DeleteSecret(name string) error {
|
||||||
secretName := s.scopedName(name)
|
secretName := s.scopedName(name)
|
||||||
if err := s.ensureReady(); err != nil {
|
item, err := s.findItem(secretName, name)
|
||||||
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 errors.Is(err, ErrNotFound) {
|
||||||
if s.cache != nil {
|
|
||||||
s.cache.invalidate(name, secretName)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -373,9 +296,6 @@ func (s *bitwardenStore) DeleteSecret(name string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cache != nil {
|
|
||||||
s.cache.invalidate(name, secretName)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,30 +303,7 @@ func (s *bitwardenStore) scopedName(name string) string {
|
||||||
return fmt.Sprintf("%s/%s", s.serviceName, name)
|
return fmt.Sprintf("%s/%s", s.serviceName, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *bitwardenStore) ensureReady() error {
|
func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, 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(
|
output, err := s.execute(
|
||||||
fmt.Sprintf("list bitwarden items for secret %q", secretName),
|
fmt.Sprintf("list bitwarden items for secret %q", secretName),
|
||||||
nil,
|
nil,
|
||||||
|
|
@ -416,15 +313,15 @@ func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenLi
|
||||||
secretName,
|
secretName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bitwardenListItem{}, nil, err
|
return bitwardenListItem{}, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(string(output)) == "" {
|
if strings.TrimSpace(string(output)) == "" {
|
||||||
return bitwardenListItem{}, nil, ErrNotFound
|
return bitwardenListItem{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
var items []bitwardenListItem
|
var items []bitwardenListItem
|
||||||
if err := json.Unmarshal(output, &items); err != nil {
|
if err := json.Unmarshal(output, &items); err != nil {
|
||||||
return bitwardenListItem{}, nil, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err)
|
return bitwardenListItem{}, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := make([]bitwardenListItem, 0, len(items))
|
matches := make([]bitwardenListItem, 0, len(items))
|
||||||
|
|
@ -439,47 +336,42 @@ func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenLi
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return bitwardenListItem{}, nil, ErrNotFound
|
return bitwardenListItem{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
markedMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
markedMatches := make([]bitwardenListItem, 0, len(matches))
|
||||||
legacyMatches := make([]bitwardenResolvedItem, 0, len(matches))
|
legacyMatches := make([]bitwardenListItem, 0, len(matches))
|
||||||
for _, item := range matches {
|
for _, item := range matches {
|
||||||
payload, err := s.itemByID(item.ID)
|
payload, err := s.itemByID(item.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bitwardenListItem{}, nil, err
|
return bitwardenListItem{}, err
|
||||||
}
|
|
||||||
|
|
||||||
resolved := bitwardenResolvedItem{
|
|
||||||
item: item,
|
|
||||||
payload: payload,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) {
|
if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) {
|
||||||
markedMatches = append(markedMatches, resolved)
|
markedMatches = append(markedMatches, item)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
legacyMatches = append(legacyMatches, resolved)
|
legacyMatches = append(legacyMatches, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch len(markedMatches) {
|
switch len(markedMatches) {
|
||||||
case 0:
|
case 0:
|
||||||
switch len(legacyMatches) {
|
switch len(legacyMatches) {
|
||||||
case 0:
|
case 0:
|
||||||
return bitwardenListItem{}, nil, ErrNotFound
|
return bitwardenListItem{}, ErrNotFound
|
||||||
case 1:
|
case 1:
|
||||||
return legacyMatches[0].item, legacyMatches[0].payload, nil
|
return legacyMatches[0], nil
|
||||||
default:
|
default:
|
||||||
return bitwardenListItem{}, nil, fmt.Errorf(
|
return bitwardenListItem{}, fmt.Errorf(
|
||||||
"multiple legacy bitwarden items match secret %q for service %q",
|
"multiple legacy bitwarden items match secret %q for service %q",
|
||||||
secretName,
|
secretName,
|
||||||
s.serviceName,
|
s.serviceName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case 1:
|
case 1:
|
||||||
return markedMatches[0].item, markedMatches[0].payload, nil
|
return markedMatches[0], nil
|
||||||
default:
|
default:
|
||||||
return bitwardenListItem{}, nil, fmt.Errorf(
|
return bitwardenListItem{}, fmt.Errorf(
|
||||||
"multiple bitwarden items share marker for secret %q and service %q",
|
"multiple bitwarden items share marker for secret %q and service %q",
|
||||||
secretName,
|
secretName,
|
||||||
s.serviceName,
|
s.serviceName,
|
||||||
|
|
@ -546,7 +438,7 @@ func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) {
|
func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) {
|
||||||
output, err := runBitwardenCommand(s.command, s.debug, stdin, args...)
|
output, err := runBitwardenCLI(s.command, stdin, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||||
}
|
}
|
||||||
|
|
@ -629,86 +521,8 @@ func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName
|
||||||
strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName)
|
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) {
|
func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||||
stopLoader := startBitwardenLoaderFunc()
|
stopLoader := startBitwardenLoader()
|
||||||
defer stopLoader()
|
defer stopLoader()
|
||||||
|
|
||||||
cmd := exec.Command(command, args...)
|
cmd := exec.Command(command, args...)
|
||||||
|
|
@ -728,38 +542,6 @@ func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte,
|
||||||
return stdout.Bytes(), nil
|
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() {
|
func startBitwardenLoader() func() {
|
||||||
if !shouldShowBitwardenLoader() {
|
if !shouldShowBitwardenLoader() {
|
||||||
return func() {}
|
return func() {}
|
||||||
|
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
package secretstore
|
package secretstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -15,7 +12,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||||
|
|
@ -34,29 +31,23 @@ func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||||
if _, ok := store.(*bitwardenStore); !ok {
|
if _, ok := store.(*bitwardenStore); !ok {
|
||||||
t.Fatalf("store type = %T, want *bitwardenStore", store)
|
t.Fatalf("store type = %T, want *bitwardenStore", store)
|
||||||
}
|
}
|
||||||
if fakeCLI.versionChecked {
|
if !fakeCLI.versionChecked {
|
||||||
t.Fatal("Open should not check bitwarden CLI version before a cache miss or write")
|
t.Fatal("expected bitwarden CLI version check")
|
||||||
}
|
}
|
||||||
if fakeCLI.statusChecked {
|
if !fakeCLI.statusChecked {
|
||||||
t.Fatal("Open should not check bitwarden CLI status before a cache miss or write")
|
t.Fatal("expected bitwarden CLI status check")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
|
func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
|
||||||
withBitwardenSession(t)
|
|
||||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||||
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
|
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
|
||||||
})
|
})
|
||||||
|
|
||||||
store, err := Open(Options{
|
_, err := Open(Options{
|
||||||
ServiceName: "email-mcp",
|
ServiceName: "email-mcp",
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
BackendPolicy: BackendBitwardenCLI,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = store.GetSecret("api-token")
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
|
|
@ -65,31 +56,26 @@ func TestBitwardenStoreMissReturnsUnavailableWhenCommandIsMissing(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBitwardenStoreMissFailsWhenSessionIsMissing(t *testing.T) {
|
func TestOpenBitwardenCLIFailsWhenSessionIsMissing(t *testing.T) {
|
||||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
fakeCLI := newFakeBitwardenCLI("bw")
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
withBitwardenRunner(t, fakeCLI.run)
|
||||||
|
|
||||||
store, err := Open(Options{
|
_, err := Open(Options{
|
||||||
ServiceName: "email-mcp",
|
ServiceName: "email-mcp",
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
BackendPolicy: BackendBitwardenCLI,
|
||||||
LookupEnv: func(name string) (string, bool) {
|
LookupEnv: func(name string) (string, bool) {
|
||||||
return "", false
|
return "", false
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = store.GetSecret("api-token")
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
if !errors.Is(err, ErrBWLocked) {
|
if !errors.Is(err, ErrBWLocked) {
|
||||||
t.Fatalf("error = %v, want ErrBWLocked", err)
|
t.Fatalf("error = %v, want ErrBWLocked", err)
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, ErrBackendUnavailable) {
|
||||||
|
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) {
|
func TestEnsureBitwardenReadyGuidesLoginAndUnlock(t *testing.T) {
|
||||||
|
|
@ -349,235 +335,7 @@ func TestBitwardenStoreFallsBackToSingleLegacyItemWithoutMarkers(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBitwardenStoreGetSecretReadsSelectedItemOnlyOnce(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := store.GetSecret("api-token")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
if value != "secret-v1" {
|
|
||||||
t.Fatalf("GetSecret = %q, want secret-v1", value)
|
|
||||||
}
|
|
||||||
if fakeCLI.getItemCalls != 1 {
|
|
||||||
t.Fatalf("bw get item count = %d, want 1", fakeCLI.getItemCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenStoreGetSecretUsesMemoryCache(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
value, err := store.GetSecret("api-token")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
|
||||||
}
|
|
||||||
if value != "secret-v1" {
|
|
||||||
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fakeCLI.getItemCalls != 1 {
|
|
||||||
t.Fatalf("bw get item count = %d, want 1 with memory cache", fakeCLI.getItemCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenStoreDiskCacheHitSkipsBitwardenCLI(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
cacheRoot := t.TempDir()
|
|
||||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
||||||
return cacheRoot, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
cache := newBitwardenCache(bitwardenCacheOptions{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
Session: os.Getenv("BW_SESSION"),
|
|
||||||
TTL: defaultBitwardenCacheTTL,
|
|
||||||
CacheDir: resolveBitwardenCacheDir("email-mcp"),
|
|
||||||
Enabled: true,
|
|
||||||
})
|
|
||||||
cache.store("api-token", "email-mcp/api-token", "secret-from-cache")
|
|
||||||
|
|
||||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
|
||||||
return nil, fmt.Errorf("unexpected bitwarden invocation: %v", args)
|
|
||||||
})
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := store.GetSecret("api-token")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
if value != "secret-from-cache" {
|
|
||||||
t.Fatalf("GetSecret = %q, want secret-from-cache", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenStoreCacheMissChecksReadinessLazily(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
if fakeCLI.statusChecked {
|
|
||||||
t.Fatal("Open should not check bitwarden status")
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := store.GetSecret("api-token")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
if value != "secret-v1" {
|
|
||||||
t.Fatalf("GetSecret = %q, want secret-v1", value)
|
|
||||||
}
|
|
||||||
if !fakeCLI.statusChecked {
|
|
||||||
t.Fatal("cache miss should check bitwarden status before CLI lookup")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenStoreCacheDisabledByEnv(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
t.Setenv("MCP_FRAMEWORK_BITWARDEN_CACHE", "0")
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
if _, err := store.GetSecret("api-token"); err != nil {
|
|
||||||
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fakeCLI.getItemCalls != 2 {
|
|
||||||
t.Fatalf("bw get item count = %d, want 2 when env disables cache", fakeCLI.getItemCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenStoreSetSecretRefreshesCache(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
||||||
t.Fatalf("SetSecret v1 returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v1" {
|
|
||||||
t.Fatalf("GetSecret after v1 = %q, %v; want secret-v1, nil", got, err)
|
|
||||||
}
|
|
||||||
if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil {
|
|
||||||
t.Fatalf("SetSecret v2 returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got, err := store.GetSecret("api-token"); err != nil || got != "secret-v2" {
|
|
||||||
t.Fatalf("GetSecret after v2 = %q, %v; want secret-v2, nil", got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenStoreDeleteSecretInvalidatesCache(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
||||||
t.Fatalf("SetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.GetSecret("api-token"); err != nil {
|
|
||||||
t.Fatalf("GetSecret before delete returned error: %v", err)
|
|
||||||
}
|
|
||||||
if err := store.DeleteSecret("api-token"); err != nil {
|
|
||||||
t.Fatalf("DeleteSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
_, err = store.GetSecret("api-token")
|
|
||||||
if !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
|
func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
|
||||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
|
store := &bitwardenStore{command: "bw", serviceName: "email-mcp"}
|
||||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||||
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
|
if len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search" {
|
||||||
|
|
@ -586,7 +344,7 @@ func TestBitwardenFindItemTreatsEmptyListOutputAsNotFound(t *testing.T) {
|
||||||
return nil, fmt.Errorf("unexpected args: %v", args)
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||||
})
|
})
|
||||||
|
|
||||||
_, _, err := store.findItem("email-mcp/api-token", "api-token")
|
_, err := store.findItem("email-mcp/api-token", "api-token")
|
||||||
if !errors.Is(err, ErrNotFound) {
|
if !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("error = %v, want ErrNotFound", err)
|
t.Fatalf("error = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
|
|
@ -609,72 +367,6 @@ func TestExecuteBitwardenCLIClassifiesLoginError(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenBitwardenCLIDebugFromEnvPrintsBitwardenCalls(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
t.Setenv("MCP_FRAMEWORK_BITWARDEN_DEBUG", "1")
|
|
||||||
|
|
||||||
var logs bytes.Buffer
|
|
||||||
withBitwardenDebugOutput(t, &logs)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.GetSecret("api-token"); err != nil {
|
|
||||||
t.Fatalf("GetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := logs.String()
|
|
||||||
if !strings.Contains(text, "bw --version") {
|
|
||||||
t.Fatalf("debug logs = %q, want command bw --version", text)
|
|
||||||
}
|
|
||||||
if !strings.Contains(text, "bw status") {
|
|
||||||
t.Fatalf("debug logs = %q, want command bw status", text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenDebugRedactsSensitivePayloadArguments(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
var logs bytes.Buffer
|
|
||||||
withBitwardenDebugOutput(t, &logs)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "graylog-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
BitwardenDebug: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
|
||||||
t.Fatalf("SetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
text := logs.String()
|
|
||||||
if !strings.Contains(text, "bw create item <redacted>") {
|
|
||||||
t.Fatalf("debug logs = %q, want redacted create payload", text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) {
|
func TestBitwardenLoaderFrameUsesSingleLineRewriteAndMessage(t *testing.T) {
|
||||||
frame := bitwardenLoaderFrame(0)
|
frame := bitwardenLoaderFrame(0)
|
||||||
if !strings.HasPrefix(frame, "\r\033[2K") {
|
if !strings.HasPrefix(frame, "\r\033[2K") {
|
||||||
|
|
@ -705,28 +397,6 @@ func TestBitwardenLoaderFrameMovesAndWrapsTheWave(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteBitwardenCLIInteractiveSkipsLoader(t *testing.T) {
|
|
||||||
loaderStartCount := 0
|
|
||||||
withBitwardenLoaderStarter(t, func() func() {
|
|
||||||
loaderStartCount++
|
|
||||||
return func() {}
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := executeBitwardenCLIInteractive(
|
|
||||||
os.Args[0],
|
|
||||||
nil,
|
|
||||||
io.Discard,
|
|
||||||
io.Discard,
|
|
||||||
"-test.run=^$",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeBitwardenCLIInteractive returned error: %v", err)
|
|
||||||
}
|
|
||||||
if loaderStartCount != 0 {
|
|
||||||
t.Fatalf("loader start count = %d, want 0 for interactive command", loaderStartCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
var ansiControlSequencePattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
|
||||||
|
|
||||||
func stripANSIControlSequences(value string) string {
|
func stripANSIControlSequences(value string) string {
|
||||||
|
|
@ -734,84 +404,6 @@ func stripANSIControlSequences(value string) string {
|
||||||
return strings.ReplaceAll(noANSI, "\r", "")
|
return strings.ReplaceAll(noANSI, "\r", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBitwardenStorePicksUpSessionRotatedByAnotherProcess(t *testing.T) {
|
|
||||||
t.Setenv("BW_SESSION", "old-session")
|
|
||||||
configDir := t.TempDir()
|
|
||||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
||||||
return configDir, nil
|
|
||||||
})
|
|
||||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "new-session"); err != nil {
|
|
||||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.GetSecret("api-token"); err != nil {
|
|
||||||
t.Fatalf("GetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got := os.Getenv("BW_SESSION"); got != "new-session" {
|
|
||||||
t.Fatalf("BW_SESSION = %q, want new-session after session rotation", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBitwardenStorePicksUpSessionFromFileWhenEnvIsEmpty(t *testing.T) {
|
|
||||||
t.Setenv("BW_SESSION", "")
|
|
||||||
configDir := t.TempDir()
|
|
||||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
||||||
return configDir, nil
|
|
||||||
})
|
|
||||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
store, err := Open(Options{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
BackendPolicy: BackendBitwardenCLI,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}, "file-session"); err != nil {
|
|
||||||
t.Fatalf("SaveBitwardenSession returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.GetSecret("api-token"); err != nil {
|
|
||||||
t.Fatalf("GetSecret returned error: %v", err)
|
|
||||||
}
|
|
||||||
if got := os.Getenv("BW_SESSION"); got != "file-session" {
|
|
||||||
t.Fatalf("BW_SESSION = %q, want file-session loaded from file after login by another process", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||||
withBitwardenSession(t)
|
withBitwardenSession(t)
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
fakeCLI := newFakeBitwardenCLI("bw")
|
||||||
|
|
@ -847,14 +439,7 @@ func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||||
|
|
||||||
func withBitwardenSession(t *testing.T) {
|
func withBitwardenSession(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sessionName := strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
|
t.Setenv("BW_SESSION", "test-session")
|
||||||
t.Setenv("BW_SESSION", "test-session-"+sessionName)
|
|
||||||
withBitwardenUserCacheDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
withBitwardenUserConfigDir(t, func() (string, error) {
|
|
||||||
return t.TempDir(), nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func withBitwardenRunner(
|
func withBitwardenRunner(
|
||||||
|
|
@ -870,26 +455,6 @@ func withBitwardenRunner(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func withBitwardenDebugOutput(t *testing.T, writer io.Writer) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
previous := bitwardenDebugOutput
|
|
||||||
bitwardenDebugOutput = writer
|
|
||||||
t.Cleanup(func() {
|
|
||||||
bitwardenDebugOutput = previous
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func withBitwardenLoaderStarter(t *testing.T, starter func() func()) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
previous := startBitwardenLoaderFunc
|
|
||||||
startBitwardenLoaderFunc = starter
|
|
||||||
t.Cleanup(func() {
|
|
||||||
startBitwardenLoaderFunc = previous
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeBitwardenCLI struct {
|
type fakeBitwardenCLI struct {
|
||||||
command string
|
command string
|
||||||
itemsByID map[string]fakeBitwardenItem
|
itemsByID map[string]fakeBitwardenItem
|
||||||
|
|
@ -897,7 +462,6 @@ type fakeBitwardenCLI struct {
|
||||||
status string
|
status string
|
||||||
versionChecked bool
|
versionChecked bool
|
||||||
statusChecked bool
|
statusChecked bool
|
||||||
getItemCalls int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeBitwardenItem struct {
|
type fakeBitwardenItem struct {
|
||||||
|
|
@ -939,7 +503,6 @@ func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([]
|
||||||
case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search":
|
case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search":
|
||||||
return f.handleListItems(args[3])
|
return f.handleListItems(args[3])
|
||||||
case len(args) == 3 && args[0] == "get" && args[1] == "item":
|
case len(args) == 3 && args[0] == "get" && args[1] == "item":
|
||||||
f.getItemCalls++
|
|
||||||
return f.handleGetItem(args[2])
|
return f.handleGetItem(args[2])
|
||||||
case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item":
|
case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item":
|
||||||
return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil
|
return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ManifestLoader func(startDir string) (manifest.File, string, error)
|
type ManifestLoader func(startDir string) (manifest.File, string, error)
|
||||||
|
|
@ -15,16 +15,14 @@ type ManifestLoader func(startDir string) (manifest.File, string, error)
|
||||||
type ExecutableResolver func() (string, error)
|
type ExecutableResolver func() (string, error)
|
||||||
|
|
||||||
type OpenFromManifestOptions struct {
|
type OpenFromManifestOptions struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
LookupEnv func(string) (string, bool)
|
LookupEnv func(string) (string, bool)
|
||||||
KWalletAppID string
|
KWalletAppID string
|
||||||
KWalletFolder string
|
KWalletFolder string
|
||||||
BitwardenCommand string
|
BitwardenCommand string
|
||||||
BitwardenDebug bool
|
Shell string
|
||||||
DisableBitwardenCache bool
|
ManifestLoader ManifestLoader
|
||||||
Shell string
|
ExecutableResolver ExecutableResolver
|
||||||
ManifestLoader ManifestLoader
|
|
||||||
ExecutableResolver ExecutableResolver
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
||||||
|
|
@ -34,15 +32,13 @@ func OpenFromManifest(options OpenFromManifestOptions) (Store, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Open(Options{
|
return Open(Options{
|
||||||
ServiceName: options.ServiceName,
|
ServiceName: options.ServiceName,
|
||||||
BackendPolicy: manifestPolicy.Policy,
|
BackendPolicy: manifestPolicy.Policy,
|
||||||
LookupEnv: options.LookupEnv,
|
LookupEnv: options.LookupEnv,
|
||||||
KWalletAppID: options.KWalletAppID,
|
KWalletAppID: options.KWalletAppID,
|
||||||
KWalletFolder: options.KWalletFolder,
|
KWalletFolder: options.KWalletFolder,
|
||||||
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
|
BitwardenCommand: strings.TrimSpace(options.BitwardenCommand),
|
||||||
BitwardenDebug: options.BitwardenDebug,
|
Shell: strings.TrimSpace(options.Shell),
|
||||||
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, manifestPolicy.BitwardenCache),
|
|
||||||
Shell: strings.TrimSpace(options.Shell),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,9 +51,8 @@ func resolveManifestBackendPolicy(options OpenFromManifestOptions) (BackendPolic
|
||||||
}
|
}
|
||||||
|
|
||||||
type manifestPolicyResolution struct {
|
type manifestPolicyResolution struct {
|
||||||
Policy BackendPolicy
|
Policy BackendPolicy
|
||||||
Source string
|
Source string
|
||||||
BitwardenCache bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) {
|
func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResolution, error) {
|
||||||
|
|
@ -85,24 +80,17 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return manifestPolicyResolution{
|
return manifestPolicyResolution{
|
||||||
Policy: BackendAuto,
|
Policy: BackendAuto,
|
||||||
Source: "",
|
Source: "",
|
||||||
BitwardenCache: true,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
|
return manifestPolicyResolution{}, fmt.Errorf("load runtime manifest from %q: %w", startDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bitwardenCache := true
|
|
||||||
if file.SecretStore.BitwardenCache != nil {
|
|
||||||
bitwardenCache = *file.SecretStore.BitwardenCache
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
|
if strings.TrimSpace(file.SecretStore.BackendPolicy) == "" {
|
||||||
return manifestPolicyResolution{
|
return manifestPolicyResolution{
|
||||||
Policy: BackendAuto,
|
Policy: BackendAuto,
|
||||||
Source: strings.TrimSpace(manifestPath),
|
Source: strings.TrimSpace(manifestPath),
|
||||||
BitwardenCache: bitwardenCache,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,12 +104,7 @@ func resolveManifestPolicy(options OpenFromManifestOptions) (manifestPolicyResol
|
||||||
}
|
}
|
||||||
|
|
||||||
return manifestPolicyResolution{
|
return manifestPolicyResolution{
|
||||||
Policy: policy,
|
Policy: policy,
|
||||||
Source: strings.TrimSpace(manifestPath),
|
Source: strings.TrimSpace(manifestPath),
|
||||||
BitwardenCache: bitwardenCache,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func disableBitwardenCacheOption(runtimeDisabled bool, manifestEnabled bool) bool {
|
|
||||||
return runtimeDisabled || !manifestEnabled
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/99designs/keyring"
|
"github.com/99designs/keyring"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {
|
func TestOpenFromManifestUsesPolicyFromManifest(t *testing.T) {
|
||||||
|
|
@ -111,78 +111,6 @@ func TestOpenFromManifestReturnsExplicitErrorForInvalidManifestPolicy(t *testing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveManifestPolicyPreservesBitwardenCacheDisable(t *testing.T) {
|
|
||||||
cacheDisabled := false
|
|
||||||
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
ExecutableResolver: func() (string, error) {
|
|
||||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
|
||||||
},
|
|
||||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
|
||||||
return manifest.File{
|
|
||||||
SecretStore: manifest.SecretStore{
|
|
||||||
BackendPolicy: string(BackendBitwardenCLI),
|
|
||||||
BitwardenCache: &cacheDisabled,
|
|
||||||
},
|
|
||||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolveManifestPolicy returned error: %v", err)
|
|
||||||
}
|
|
||||||
if resolution.BitwardenCache {
|
|
||||||
t.Fatal("resolution BitwardenCache = true, want false")
|
|
||||||
}
|
|
||||||
if resolution.Policy != BackendBitwardenCLI {
|
|
||||||
t.Fatalf("resolution policy = %q, want %q", resolution.Policy, BackendBitwardenCLI)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenFromManifestAppliesBitwardenCacheDisable(t *testing.T) {
|
|
||||||
withBitwardenSession(t)
|
|
||||||
fakeCLI := newFakeBitwardenCLI("bw")
|
|
||||||
fakeCLI.itemsByID["item-1"] = fakeBitwardenItem{
|
|
||||||
ID: "item-1",
|
|
||||||
Name: "email-mcp/api-token",
|
|
||||||
Secret: "secret-v1",
|
|
||||||
MarkerService: "email-mcp",
|
|
||||||
MarkerSecretName: "api-token",
|
|
||||||
}
|
|
||||||
withBitwardenRunner(t, fakeCLI.run)
|
|
||||||
|
|
||||||
cacheDisabled := false
|
|
||||||
store, err := OpenFromManifest(OpenFromManifestOptions{
|
|
||||||
ServiceName: "email-mcp",
|
|
||||||
ExecutableResolver: func() (string, error) {
|
|
||||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
|
||||||
},
|
|
||||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
|
||||||
return manifest.File{
|
|
||||||
SecretStore: manifest.SecretStore{
|
|
||||||
BackendPolicy: string(BackendBitwardenCLI),
|
|
||||||
BitwardenCache: &cacheDisabled,
|
|
||||||
},
|
|
||||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenFromManifest returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
value, err := store.GetSecret("api-token")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSecret #%d returned error: %v", i+1, err)
|
|
||||||
}
|
|
||||||
if value != "secret-v1" {
|
|
||||||
t.Fatalf("GetSecret #%d = %q, want secret-v1", i+1, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fakeCLI.getItemCalls != 2 {
|
|
||||||
t.Fatalf("bw get item count = %d, want 2 when manifest disables cache", fakeCLI.getItemCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) {
|
func TestOpenFromManifestReturnsExecutableResolutionError(t *testing.T) {
|
||||||
execErr := errors.New("boom")
|
execErr := errors.New("boom")
|
||||||
_, err := OpenFromManifest(OpenFromManifestOptions{
|
_, err := OpenFromManifest(OpenFromManifestOptions{
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,14 @@ import (
|
||||||
const DefaultManifestSource = "default:auto (manifest not found)"
|
const DefaultManifestSource = "default:auto (manifest not found)"
|
||||||
|
|
||||||
type DescribeRuntimeOptions struct {
|
type DescribeRuntimeOptions struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
LookupEnv func(string) (string, bool)
|
LookupEnv func(string) (string, bool)
|
||||||
KWalletAppID string
|
KWalletAppID string
|
||||||
KWalletFolder string
|
KWalletFolder string
|
||||||
BitwardenCommand string
|
BitwardenCommand string
|
||||||
BitwardenDebug bool
|
Shell string
|
||||||
DisableBitwardenCache bool
|
ManifestLoader ManifestLoader
|
||||||
CheckReady bool
|
ExecutableResolver ExecutableResolver
|
||||||
Shell string
|
|
||||||
ManifestLoader ManifestLoader
|
|
||||||
ExecutableResolver ExecutableResolver
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeDescription struct {
|
type RuntimeDescription struct {
|
||||||
|
|
@ -49,15 +46,14 @@ type PreflightReport struct {
|
||||||
|
|
||||||
func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) {
|
func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error) {
|
||||||
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
resolution, err := resolveManifestPolicy(OpenFromManifestOptions{
|
||||||
ServiceName: options.ServiceName,
|
ServiceName: options.ServiceName,
|
||||||
LookupEnv: options.LookupEnv,
|
LookupEnv: options.LookupEnv,
|
||||||
KWalletAppID: options.KWalletAppID,
|
KWalletAppID: options.KWalletAppID,
|
||||||
KWalletFolder: options.KWalletFolder,
|
KWalletFolder: options.KWalletFolder,
|
||||||
BitwardenCommand: options.BitwardenCommand,
|
BitwardenCommand: options.BitwardenCommand,
|
||||||
DisableBitwardenCache: options.DisableBitwardenCache,
|
Shell: options.Shell,
|
||||||
Shell: options.Shell,
|
ManifestLoader: options.ManifestLoader,
|
||||||
ManifestLoader: options.ManifestLoader,
|
ExecutableResolver: options.ExecutableResolver,
|
||||||
ExecutableResolver: options.ExecutableResolver,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RuntimeDescription{}, err
|
return RuntimeDescription{}, err
|
||||||
|
|
@ -71,15 +67,13 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
store, openErr := Open(Options{
|
store, openErr := Open(Options{
|
||||||
ServiceName: options.ServiceName,
|
ServiceName: options.ServiceName,
|
||||||
BackendPolicy: resolution.Policy,
|
BackendPolicy: resolution.Policy,
|
||||||
LookupEnv: options.LookupEnv,
|
LookupEnv: options.LookupEnv,
|
||||||
KWalletAppID: options.KWalletAppID,
|
KWalletAppID: options.KWalletAppID,
|
||||||
KWalletFolder: options.KWalletFolder,
|
KWalletFolder: options.KWalletFolder,
|
||||||
BitwardenCommand: options.BitwardenCommand,
|
BitwardenCommand: options.BitwardenCommand,
|
||||||
BitwardenDebug: options.BitwardenDebug,
|
Shell: options.Shell,
|
||||||
DisableBitwardenCache: disableBitwardenCacheOption(options.DisableBitwardenCache, resolution.BitwardenCache),
|
|
||||||
Shell: options.Shell,
|
|
||||||
})
|
})
|
||||||
if openErr != nil {
|
if openErr != nil {
|
||||||
desc.Ready = false
|
desc.Ready = false
|
||||||
|
|
@ -92,23 +86,11 @@ func DescribeRuntime(options DescribeRuntimeOptions) (RuntimeDescription, error)
|
||||||
desc.EffectivePolicy = effective
|
desc.EffectivePolicy = effective
|
||||||
desc.DisplayName = BackendDisplayName(effective)
|
desc.DisplayName = BackendDisplayName(effective)
|
||||||
}
|
}
|
||||||
if options.CheckReady && desc.EffectivePolicy == BackendBitwardenCLI {
|
|
||||||
if err := verifyBitwardenCLIReady(Options{
|
|
||||||
BitwardenCommand: options.BitwardenCommand,
|
|
||||||
BitwardenDebug: options.BitwardenDebug,
|
|
||||||
LookupEnv: options.LookupEnv,
|
|
||||||
Shell: options.Shell,
|
|
||||||
}); err != nil {
|
|
||||||
desc.Ready = false
|
|
||||||
desc.ReadyError = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return desc, nil
|
return desc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) {
|
func PreflightFromManifest(options PreflightOptions) (PreflightReport, error) {
|
||||||
options.CheckReady = true
|
|
||||||
desc, err := DescribeRuntime(options)
|
desc, err := DescribeRuntime(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PreflightReport{}, err
|
return PreflightReport{}, err
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lclr.dev/AI/mcp-framework/manifest"
|
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
|
func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
|
||||||
|
|
@ -46,9 +46,16 @@ func TestDescribeRuntimeReturnsDeclaredAndEffectivePolicies(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) {
|
func TestDescribeRuntimeReportsUnavailableBitwardenAsNotReady(t *testing.T) {
|
||||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||||
return nil, errors.New("unexpected bitwarden invocation")
|
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")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
desc, err := DescribeRuntime(DescribeRuntimeOptions{
|
desc, err := DescribeRuntime(DescribeRuntimeOptions{
|
||||||
|
|
@ -73,11 +80,14 @@ func TestDescribeRuntimeDoesNotProbeBitwardenByDefault(t *testing.T) {
|
||||||
if desc.EffectivePolicy != BackendBitwardenCLI {
|
if desc.EffectivePolicy != BackendBitwardenCLI {
|
||||||
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
|
t.Fatalf("EffectivePolicy = %q, want %q", desc.EffectivePolicy, BackendBitwardenCLI)
|
||||||
}
|
}
|
||||||
if !desc.Ready {
|
if desc.Ready {
|
||||||
t.Fatalf("Ready = %v, want true without readiness probe", desc.Ready)
|
t.Fatalf("Ready = %v, want false", desc.Ready)
|
||||||
}
|
}
|
||||||
if desc.ReadyError != nil {
|
if !errors.Is(desc.ReadyError, ErrBWLocked) {
|
||||||
t.Fatalf("ReadyError = %v, want nil without readiness probe", desc.ReadyError)
|
t.Fatalf("ReadyError = %v, want ErrBWLocked", desc.ReadyError)
|
||||||
|
}
|
||||||
|
if !strings.Contains(desc.ReadyError.Error(), "set -x BW_SESSION (bw unlock --raw)") {
|
||||||
|
t.Fatalf("ReadyError = %v, want fish remediation", desc.ReadyError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,13 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
BackendPolicy BackendPolicy
|
BackendPolicy BackendPolicy
|
||||||
LookupEnv func(string) (string, bool)
|
LookupEnv func(string) (string, bool)
|
||||||
KWalletAppID string
|
KWalletAppID string
|
||||||
KWalletFolder string
|
KWalletFolder string
|
||||||
BitwardenCommand string
|
BitwardenCommand string
|
||||||
BitwardenDebug bool
|
Shell string
|
||||||
DisableBitwardenCache bool
|
|
||||||
Shell string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue