feat: add toml manifest loader for mcp projects
This commit is contained in:
parent
1b250b2cc7
commit
867d761f5c
5 changed files with 255 additions and 0 deletions
13
README.md
13
README.md
|
|
@ -5,6 +5,7 @@ Bibliotheque Go pour construire des binaires MCP avec :
|
|||
- resolution de profils CLI
|
||||
- stockage JSON de configuration dans `os.UserConfigDir()`
|
||||
- stockage de secrets dans le wallet natif selon l'OS
|
||||
- lecture d'un manifeste `mcp.toml` a la racine du projet
|
||||
- pipeline d'auto-update via endpoint de release configurable
|
||||
|
||||
Le package `update` ne deduit pas la forge ni l'authentification.
|
||||
|
|
@ -15,5 +16,17 @@ Packages exposes :
|
|||
|
||||
- `cli`
|
||||
- `config`
|
||||
- `manifest`
|
||||
- `secretstore`
|
||||
- `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
1
go.mod
|
|
@ -4,6 +4,7 @@ go 1.25.0
|
|||
|
||||
require (
|
||||
github.com/99designs/keyring v1.2.2
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
golang.org/x/term v0.40.0
|
||||
)
|
||||
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -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/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
|
||||
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/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
123
manifest/manifest.go
Normal file
123
manifest/manifest.go
Normal 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
116
manifest/manifest_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue