feat: add toml manifest loader for mcp projects

This commit is contained in:
thibaud-leclere 2026-04-13 15:52:00 +02:00
parent 1b250b2cc7
commit 867d761f5c
5 changed files with 255 additions and 0 deletions

View file

@ -5,6 +5,7 @@ Bibliotheque Go pour construire des binaires MCP avec :
- resolution de profils CLI - resolution de profils CLI
- stockage JSON de configuration dans `os.UserConfigDir()` - stockage JSON de configuration dans `os.UserConfigDir()`
- stockage de secrets dans le wallet natif selon l'OS - stockage de secrets dans le wallet natif selon l'OS
- lecture d'un manifeste `mcp.toml` a la racine du projet
- pipeline d'auto-update via endpoint de release configurable - pipeline d'auto-update via endpoint de release configurable
Le package `update` ne deduit pas la forge ni l'authentification. Le package `update` ne deduit pas la forge ni l'authentification.
@ -15,5 +16,17 @@ Packages exposes :
- `cli` - `cli`
- `config` - `config`
- `manifest`
- `secretstore` - `secretstore`
- `update` - `update`
Exemple minimal de `mcp.toml` :
```toml
[update]
source_name = "Gitea releases"
base_url = "https://gitea.example.com"
latest_release_url = "https://gitea.example.com/api/v1/repos/org/repo/releases/latest"
token_header = "Authorization"
token_env_names = ["GITEA_TOKEN"]
```

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.25.0
require ( require (
github.com/99designs/keyring v1.2.2 github.com/99designs/keyring v1.2.2
github.com/BurntSushi/toml v1.6.0
golang.org/x/term v0.40.0 golang.org/x/term v0.40.0
) )

2
go.sum
View file

@ -2,6 +2,8 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMb
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

123
manifest/manifest.go Normal file
View file

@ -0,0 +1,123 @@
package manifest
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"gitea.lclr.dev/AI/mcp-framework/update"
)
const DefaultFile = "mcp.toml"
type File struct {
Update Update `toml:"update"`
}
type Update struct {
SourceName string `toml:"source_name"`
BaseURL string `toml:"base_url"`
LatestReleaseURL string `toml:"latest_release_url"`
TokenHeader string `toml:"token_header"`
TokenEnvNames []string `toml:"token_env_names"`
}
func Find(startDir string) (string, error) {
dir := strings.TrimSpace(startDir)
if dir == "" {
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve working directory: %w", err)
}
dir = wd
}
absDir, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("resolve manifest search path %q: %w", dir, err)
}
for {
path := filepath.Join(absDir, DefaultFile)
info, err := os.Stat(path)
switch {
case err == nil:
if info.IsDir() {
return "", fmt.Errorf("manifest path %q is a directory", path)
}
return path, nil
case !errors.Is(err, os.ErrNotExist):
return "", fmt.Errorf("stat manifest %q: %w", path, err)
}
parent := filepath.Dir(absDir)
if parent == absDir {
return "", fmt.Errorf("find manifest %q from %q: %w", DefaultFile, dir, os.ErrNotExist)
}
absDir = parent
}
}
func Load(path string) (File, error) {
data, err := os.ReadFile(path)
if err != nil {
return File{}, fmt.Errorf("read manifest %s: %w", path, err)
}
var file File
if err := toml.Unmarshal(data, &file); err != nil {
return File{}, fmt.Errorf("parse manifest %s: %w", path, err)
}
file.normalize()
return file, nil
}
func LoadDefault(startDir string) (File, string, error) {
path, err := Find(startDir)
if err != nil {
return File{}, "", err
}
file, err := Load(path)
if err != nil {
return File{}, "", err
}
return file, path, nil
}
func (f *File) normalize() {
f.Update.normalize()
}
func (u *Update) normalize() {
u.SourceName = strings.TrimSpace(u.SourceName)
u.BaseURL = strings.TrimRight(strings.TrimSpace(u.BaseURL), "/")
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
u.TokenHeader = strings.TrimSpace(u.TokenHeader)
envNames := u.TokenEnvNames[:0]
for _, envName := range u.TokenEnvNames {
if trimmed := strings.TrimSpace(envName); trimmed != "" {
envNames = append(envNames, trimmed)
}
}
u.TokenEnvNames = envNames
}
func (u Update) ReleaseSource() update.ReleaseSource {
u.normalize()
return update.ReleaseSource{
Name: u.SourceName,
BaseURL: u.BaseURL,
LatestReleaseURL: u.LatestReleaseURL,
TokenHeader: u.TokenHeader,
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
}
}

116
manifest/manifest_test.go Normal file
View file

@ -0,0 +1,116 @@
package manifest
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestFindWalksParents(t *testing.T) {
root := t.TempDir()
manifestPath := filepath.Join(root, DefaultFile)
if err := os.WriteFile(manifestPath, []byte("[update]\n"), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
startDir := filepath.Join(root, "cmd", "graylog")
if err := os.MkdirAll(startDir, 0o755); err != nil {
t.Fatalf("MkdirAll startDir: %v", err)
}
got, err := Find(startDir)
if err != nil {
t.Fatalf("Find returned error: %v", err)
}
if got != manifestPath {
t.Fatalf("Find = %q, want %q", got, manifestPath)
}
}
func TestFindReturnsNotExistWhenMissing(t *testing.T) {
_, err := Find(t.TempDir())
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error = %v, want os.ErrNotExist", err)
}
}
func TestLoadParsesUpdateConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
const content = `
[update]
source_name = " Gitea releases "
base_url = "https://gitea.example.com/"
latest_release_url = "https://gitea.example.com/api/releases/latest"
token_header = " Authorization "
token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
`
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)
}
source := file.Update.ReleaseSource()
if source.Name != "Gitea releases" {
t.Fatalf("source name = %q", source.Name)
}
if source.BaseURL != "https://gitea.example.com" {
t.Fatalf("base URL = %q", source.BaseURL)
}
if source.LatestReleaseURL != "https://gitea.example.com/api/releases/latest" {
t.Fatalf("latest release URL = %q", source.LatestReleaseURL)
}
if source.TokenHeader != "Authorization" {
t.Fatalf("token header = %q", source.TokenHeader)
}
if len(source.TokenEnvNames) != 2 {
t.Fatalf("token env names = %v", source.TokenEnvNames)
}
}
func TestLoadDefaultFindsManifest(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, DefaultFile)
if err := os.WriteFile(path, []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
startDir := filepath.Join(root, "cmd", "tool")
if err := os.MkdirAll(startDir, 0o755); err != nil {
t.Fatalf("MkdirAll startDir: %v", err)
}
file, gotPath, err := LoadDefault(startDir)
if err != nil {
t.Fatalf("LoadDefault returned error: %v", err)
}
if gotPath != path {
t.Fatalf("path = %q, want %q", gotPath, path)
}
if file.Update.LatestReleaseURL != "https://example.com/latest" {
t.Fatalf("latest release URL = %q", file.Update.LatestReleaseURL)
}
}
func TestLoadReturnsParseError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, DefaultFile)
if err := os.WriteFile(path, []byte("[update\n"), 0o600); err != nil {
t.Fatalf("WriteFile manifest: %v", err)
}
_, err := Load(path)
if err == nil {
t.Fatal("expected error")
}
}