Compare commits
No commits in common. "f8eb0d3449ab6d00b81df49bbe7d825cc256e69d" and "9a52b5dce1bbab451534bd0d13dab05cde097942" have entirely different histories.
f8eb0d3449
...
9a52b5dce1
9 changed files with 46 additions and 497 deletions
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"forge.lclr.dev/AI/mcp-framework/secretstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -166,6 +168,14 @@ func Run(ctx context.Context, opts Options) error {
|
||||||
}
|
}
|
||||||
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
_, err := fmt.Fprintln(normalized.Stdout, normalized.Version)
|
||||||
return err
|
return err
|
||||||
|
case CommandLogin:
|
||||||
|
_, err := secretstore.LoginBitwarden(secretstore.BitwardenLoginOptions{
|
||||||
|
ServiceName: normalized.BinaryName,
|
||||||
|
Stdin: normalized.Stdin,
|
||||||
|
Stdout: normalized.Stdout,
|
||||||
|
Stderr: normalized.Stderr,
|
||||||
|
})
|
||||||
|
return err
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
return fmt.Errorf("%w: %s", ErrCommandNotConfigured, command)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -86,7 +86,7 @@ func RunDoctor(ctx context.Context, options DoctorOptions) DoctorReport {
|
||||||
}
|
}
|
||||||
if options.ManifestCheck != nil {
|
if options.ManifestCheck != nil {
|
||||||
checks = append(checks, options.ManifestCheck)
|
checks = append(checks, options.ManifestCheck)
|
||||||
} else 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 {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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,26 @@ 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"),
|
Login: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
|
return runLogin(ctx, inv.Args)
|
||||||
|
},
|
||||||
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
MCP: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runMCP(ctx, inv.Args)
|
return runMCP(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
ConfigShow: func(ctx context.Context, inv bootstrap.Invocation) error {
|
||||||
return runConfigShow(ctx, inv.Args)
|
return runConfigShow(ctx, inv.Args)
|
||||||
},
|
},
|
||||||
ConfigTest: 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 +41,11 @@ 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 `login` est optionnelle et peut être branchée pour gérer un unlock Bitwarden interactif.
|
||||||
```go
|
La commande `config` impose une sous-commande (`show`, `test`, et optionnellement `delete`).
|
||||||
bootstrap.BitwardenLoginHandler(binaryName string) bootstrap.Handler
|
Les alias déclarés (ou `EnableDoctorAlias`) sont affichés dans l'aide globale.
|
||||||
```
|
Le flag global `--debug` est supporté et active le debug des appels Bitwarden
|
||||||
|
|
||||||
Handler prêt à l'emploi pour la commande `login` des MCPs qui utilisent le
|
|
||||||
backend Bitwarden CLI. Il lance le flux interactif `bw unlock --raw`, persiste
|
|
||||||
`BW_SESSION` dans un fichier `0600` sous le répertoire de config utilisateur, et
|
|
||||||
confirme le résultat.
|
|
||||||
|
|
||||||
À n'utiliser que si le MCP déclare `secret_store.backend_policy = "bitwarden-cli"`
|
|
||||||
dans son manifest. Pour les autres backends (`env-only`, `keyring-any`), ne pas
|
|
||||||
définir de hook `Login` : la commande est automatiquement masquée.
|
|
||||||
|
|
||||||
```go
|
|
||||||
Hooks: bootstrap.Hooks{
|
|
||||||
Login: bootstrap.BitwardenLoginHandler(mcpgen.BinaryName),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `StandardConfigTestHandler`
|
|
||||||
|
|
||||||
```go
|
|
||||||
bootstrap.StandardConfigTestHandler(opts bootstrap.StandardConfigTestOptions) bootstrap.Handler
|
|
||||||
```
|
|
||||||
|
|
||||||
Handler pour `config test` qui exécute un ensemble de checks standards et affiche
|
|
||||||
un rapport formaté. Aucun champ n'est obligatoire.
|
|
||||||
|
|
||||||
```go
|
|
||||||
type StandardConfigTestOptions struct {
|
|
||||||
ConfigCheck cli.DoctorCheck // cli.NewConfigCheck(store)
|
|
||||||
OpenStore func() (secretstore.Store, error) // check disponibilité secret store
|
|
||||||
ConnectivityCheck cli.DoctorCheck // check applicatif (HTTP, IMAP…)
|
|
||||||
ExtraChecks []cli.DoctorCheck
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Checks inclus automatiquement selon les champs fournis :
|
|
||||||
|
|
||||||
| Champ | Check résultant |
|
|
||||||
|---|---|
|
|
||||||
| `ConfigCheck` | Fichier de configuration lisible |
|
|
||||||
| `OpenStore` | Secret store disponible |
|
|
||||||
| `ConnectivityCheck` | Connectivité applicative |
|
|
||||||
| `ExtraChecks` | Checks supplémentaires |
|
|
||||||
|
|
||||||
Le `ManifestCheck` n'est pas inclus : le manifest est un artefact de build, pas
|
|
||||||
une contrainte runtime.
|
|
||||||
|
|
||||||
```go
|
|
||||||
ConfigTest: bootstrap.StandardConfigTestHandler(bootstrap.StandardConfigTestOptions{
|
|
||||||
ConfigCheck: cli.NewConfigCheck(frameworkconfig.NewStore[ProfileConfig]("my-mcp")),
|
|
||||||
OpenStore: openSecretStore,
|
|
||||||
ConnectivityCheck: func(ctx context.Context) cli.DoctorResult {
|
|
||||||
if err := pingBackend(ctx); err != nil {
|
|
||||||
return cli.DoctorResult{
|
|
||||||
Name: "connectivity",
|
|
||||||
Status: cli.DoctorStatusFail,
|
|
||||||
Summary: "backend inaccessible",
|
|
||||||
Detail: err.Error(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cli.DoctorResult{
|
|
||||||
Name: "connectivity",
|
|
||||||
Status: cli.DoctorStatusOK,
|
|
||||||
Summary: "backend accessible",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
Pour un config test applicatif spécifique (appels API, messages ✓/✗), implémenter
|
|
||||||
un hook `ConfigTest` custom.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
| Champ | Description |
|
|
||||||
|---|---|
|
|
||||||
| `BinaryName` | Nom du binaire, utilisé dans l'aide et les messages |
|
|
||||||
| `Description` | Description affichée dans l'aide globale |
|
|
||||||
| `Version` | Version affichée par `version` sans hook |
|
|
||||||
| `Args` | Arguments CLI (défaut : `os.Args[1:]`) |
|
|
||||||
| `Stdin/Stdout/Stderr` | I/O (défaut : `os.Stdin/Stdout/Stderr`) |
|
|
||||||
| `Aliases` | Alias de commandes |
|
|
||||||
| `AliasDescriptions` | Descriptions des alias dans l'aide |
|
|
||||||
| `EnableDoctorAlias` | Active `doctor` comme alias de `config test` |
|
|
||||||
| `DisabledCommands` | Commandes à masquer explicitement |
|
|
||||||
|
|
||||||
Le flag global `--debug` active le debug des appels Bitwarden
|
|
||||||
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).
|
(`MCP_FRAMEWORK_BITWARDEN_DEBUG=1`).
|
||||||
|
|
|
||||||
|
|
@ -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 :
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue