Skip to content

Commit 44a6515

Browse files
peteski22aittalam
andauthored
Implement registry caching system with configurable options (#149)
Implement registry caching system with configurable options * Add internal/cache module with functional options pattern * Add --cache-dir, --cache-ttl, --no-cache, --refresh-cache CLI flags for add + search cmds * Follow XDG Base Directory Specification for default cache location * Only create cache directory when caching is enabled * Refactor UserSpecific{Config/Cache}Dir to shared helper with XDG validation * Add comprehensive unit and integration tests for all cache behaviors * Add Registry Caching documentation with examples and troubleshooting Co-authored-by: Davide Eynard <davide.eynard@gmail.com>
1 parent d954794 commit 44a6515

File tree

16 files changed

+1665
-246
lines changed

16 files changed

+1665
-246
lines changed

cmd/add.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"io"
66
"strings"
7+
"time"
78

89
"github.com/spf13/cobra"
910

@@ -29,6 +30,10 @@ type AddCmd struct {
2930
Source string
3031
Format internalcmd.OutputFormat
3132
AllowDeprecated bool
33+
CacheDisabled bool
34+
CacheRefresh bool
35+
CacheDir string
36+
CacheTTL string
3237
cfgLoader config.Loader
3338
packagePrinter output.Printer[config.ServerEntry]
3439
registryBuilder registry.Builder
@@ -100,6 +105,39 @@ func NewAddCmd(baseCmd *internalcmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.C
100105
"Optional, allows server installations marked as deprecated to be added",
101106
)
102107

108+
cobraCommand.Flags().BoolVar(
109+
&c.CacheDisabled,
110+
"no-cache",
111+
false,
112+
"Disable registry manifest caching",
113+
)
114+
115+
cobraCommand.Flags().BoolVar(
116+
&c.CacheRefresh,
117+
"refresh-cache",
118+
false,
119+
"Force refresh of cached registry manifests",
120+
)
121+
122+
defaultCacheDir, err := regopts.DefaultCacheDir()
123+
if err != nil {
124+
return nil, fmt.Errorf("error getting default cache directory: %w", err)
125+
}
126+
127+
cobraCommand.Flags().StringVar(
128+
&c.CacheDir,
129+
"cache-dir",
130+
defaultCacheDir,
131+
"Directory for caching registry manifests",
132+
)
133+
134+
cobraCommand.Flags().StringVar(
135+
&c.CacheTTL,
136+
"cache-ttl",
137+
regopts.DefaultCacheTTL().String(),
138+
"Time-to-live for cached registry manifests (e.g. 1h, 30m, 24h)",
139+
)
140+
103141
return cobraCommand, nil
104142
}
105143

@@ -130,7 +168,17 @@ func (c *AddCmd) run(cmd *cobra.Command, args []string) error {
130168
return handler.HandleError(err)
131169
}
132170

133-
reg, err := c.registryBuilder.Build()
171+
cacheTTL, err := time.ParseDuration(c.CacheTTL)
172+
if err != nil {
173+
return handler.HandleError(fmt.Errorf("invalid cache TTL: %w", err))
174+
}
175+
176+
reg, err := c.registryBuilder.Build(
177+
regopts.WithCaching(!c.CacheDisabled),
178+
regopts.WithRefreshCache(c.CacheRefresh),
179+
regopts.WithCacheDir(c.CacheDir),
180+
regopts.WithCacheTTL(cacheTTL),
181+
)
134182
if err != nil {
135183
return handler.HandleError(err)
136184
}

cmd/add_test.go

Lines changed: 205 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"bytes"
55
"errors"
66
"io"
7+
"os"
8+
"path/filepath"
79
"testing"
810

9-
"github.com/stretchr/testify/assert"
1011
"github.com/stretchr/testify/require"
1112

1213
"github.com/mozilla-ai/mcpd/v2/internal/cmd"
@@ -72,7 +73,7 @@ type fakeBuilder struct {
7273
err error
7374
}
7475

75-
func (f *fakeBuilder) Build() (registry.PackageProvider, error) {
76+
func (f *fakeBuilder) Build(_ ...options.BuildOption) (registry.PackageProvider, error) {
7677
return f.reg, f.err
7778
}
7879

@@ -115,10 +116,10 @@ func TestAddCmd_Success(t *testing.T) {
115116

116117
err = cmdObj.Execute()
117118
require.NoError(t, err)
118-
assert.Contains(t, buf.String(), "✓ Added server")
119-
assert.True(t, cfg.addCalled)
120-
assert.Equal(t, "server1", cfg.entry.Name)
121-
assert.Equal(t, "uvx::mcp-server-1@1.2.3", cfg.entry.Package)
119+
require.Contains(t, buf.String(), "✓ Added server")
120+
require.True(t, cfg.addCalled)
121+
require.Equal(t, "server1", cfg.entry.Name)
122+
require.Equal(t, "uvx::mcp-server-1@1.2.3", cfg.entry.Package)
122123
}
123124

124125
func TestAddCmd_MissingArgs(t *testing.T) {
@@ -132,7 +133,7 @@ func TestAddCmd_MissingArgs(t *testing.T) {
132133

133134
err = cmdObj.Execute()
134135
require.Error(t, err)
135-
assert.Contains(t, err.Error(), "server name is required")
136+
require.Contains(t, err.Error(), "server name is required")
136137
}
137138

138139
func TestAddCmd_RegistryFails(t *testing.T) {
@@ -145,7 +146,7 @@ func TestAddCmd_RegistryFails(t *testing.T) {
145146
cmdObj.SetArgs([]string{"server1"})
146147
err = cmdObj.Execute()
147148
require.Error(t, err)
148-
assert.Contains(t, err.Error(), "registry error")
149+
require.Contains(t, err.Error(), "registry error")
149150
}
150151

151152
func TestAddCmd_BasicServerAdd(t *testing.T) {
@@ -187,14 +188,14 @@ func TestAddCmd_BasicServerAdd(t *testing.T) {
187188

188189
// Output assertions
189190
outStr := o.String()
190-
assert.Contains(t, outStr, "✓ Added server 'testserver'")
191-
assert.Contains(t, outStr, "version: latest")
191+
require.Contains(t, outStr, "✓ Added server 'testserver'")
192+
require.Contains(t, outStr, "version: latest")
192193

193194
// Config assertions
194195
require.True(t, cfg.addCalled)
195-
assert.Equal(t, "testserver", cfg.entry.Name)
196-
assert.Equal(t, "uvx::mcp-server-testserver@latest", cfg.entry.Package)
197-
assert.ElementsMatch(t, []string{"tool1", "tool2", "tool3"}, cfg.entry.Tools)
196+
require.Equal(t, "testserver", cfg.entry.Name)
197+
require.Equal(t, "uvx::mcp-server-testserver@latest", cfg.entry.Package)
198+
require.ElementsMatch(t, []string{"tool1", "tool2", "tool3"}, cfg.entry.Tools)
198199
}
199200

200201
func TestAddCmd_ServerWithArguments(t *testing.T) {
@@ -396,11 +397,11 @@ func TestAddCmd_ServerWithArguments(t *testing.T) {
396397

397398
// Verify config was called with correct arguments
398399
require.True(t, cfg.addCalled)
399-
assert.Equal(t, tc.pkg.ID, cfg.entry.Name)
400-
assert.ElementsMatch(t, tc.expectedRequiredEnvs, cfg.entry.RequiredEnvVars)
401-
assert.ElementsMatch(t, tc.expectedRequiredPositionals, cfg.entry.RequiredPositionalArgs)
402-
assert.ElementsMatch(t, tc.expectedRequiredValues, cfg.entry.RequiredValueArgs)
403-
assert.ElementsMatch(t, tc.expectedRequiredBools, cfg.entry.RequiredBoolArgs)
400+
require.Equal(t, tc.pkg.ID, cfg.entry.Name)
401+
require.ElementsMatch(t, tc.expectedRequiredEnvs, cfg.entry.RequiredEnvVars)
402+
require.ElementsMatch(t, tc.expectedRequiredPositionals, cfg.entry.RequiredPositionalArgs)
403+
require.ElementsMatch(t, tc.expectedRequiredValues, cfg.entry.RequiredValueArgs)
404+
require.ElementsMatch(t, tc.expectedRequiredBools, cfg.entry.RequiredBoolArgs)
404405
})
405406
}
406407
}
@@ -476,7 +477,7 @@ func TestSelectRuntime(t *testing.T) {
476477
require.EqualError(t, err, "no supported runtimes found")
477478
} else {
478479
require.NoError(t, err)
479-
assert.Equal(t, tc.expectedRuntime, got)
480+
require.Equal(t, tc.expectedRuntime, got)
480481
}
481482
})
482483
}
@@ -761,3 +762,188 @@ func TestParseServerEntry(t *testing.T) {
761762
})
762763
}
763764
}
765+
766+
func TestAddCmd_CacheTTL(t *testing.T) {
767+
t.Parallel()
768+
769+
tests := []struct {
770+
name string
771+
ttl string
772+
expectedError string
773+
}{
774+
{
775+
name: "valid cache TTL",
776+
ttl: "1h",
777+
},
778+
{
779+
name: "invalid cache TTL",
780+
ttl: "invalid",
781+
expectedError: "invalid cache TTL: time: invalid duration \"invalid\"",
782+
},
783+
}
784+
785+
for _, tc := range tests {
786+
t.Run(tc.name, func(t *testing.T) {
787+
t.Parallel()
788+
789+
pkg := packages.Server{
790+
ID: "testserver",
791+
Name: "testserver",
792+
Tools: []packages.Tool{
793+
{Name: "tool1"},
794+
},
795+
Installations: map[runtime.Runtime]packages.Installation{
796+
runtime.UVX: {
797+
Runtime: "uvx",
798+
Package: "mcp-server-testserver",
799+
Version: "latest",
800+
Recommended: true,
801+
},
802+
},
803+
}
804+
805+
cfg := &fakeConfig{}
806+
cmdObj, err := NewAddCmd(
807+
&cmd.BaseCmd{},
808+
cmdopts.WithConfigLoader(&fakeLoader{cfg: cfg}),
809+
cmdopts.WithRegistryBuilder(&fakeBuilder{reg: &fakeRegistry{pkg: pkg}}),
810+
)
811+
require.NoError(t, err)
812+
813+
cmdObj.SetOut(io.Discard)
814+
cmdObj.SetErr(io.Discard)
815+
cmdObj.SetArgs([]string{"testserver", "--cache-ttl", tc.ttl})
816+
817+
err = cmdObj.Execute()
818+
if tc.expectedError != "" {
819+
require.Error(t, err)
820+
require.EqualError(t, err, tc.expectedError)
821+
} else {
822+
require.NoError(t, err)
823+
require.True(t, cfg.addCalled)
824+
require.Equal(t, "testserver", cfg.entry.Name)
825+
}
826+
})
827+
}
828+
}
829+
830+
func TestAddCmd_CacheFlagsWithTempDir(t *testing.T) {
831+
t.Parallel()
832+
833+
tests := []struct {
834+
name string
835+
setupCmd func(t *testing.T, tempDir string) []string
836+
}{
837+
{
838+
name: "custom cache directory",
839+
setupCmd: func(t *testing.T, tempDir string) []string {
840+
return []string{"testserver", "--cache-dir", tempDir}
841+
},
842+
},
843+
{
844+
name: "both custom cache flags",
845+
setupCmd: func(t *testing.T, tempDir string) []string {
846+
return []string{"testserver", "--cache-dir", tempDir, "--cache-ttl", "30m"}
847+
},
848+
},
849+
{
850+
name: "cache disabled with custom settings",
851+
setupCmd: func(t *testing.T, tempDir string) []string {
852+
return []string{"testserver", "--no-cache", "--cache-dir", tempDir, "--cache-ttl", "2h"}
853+
},
854+
},
855+
}
856+
857+
for _, tc := range tests {
858+
t.Run(tc.name, func(t *testing.T) {
859+
t.Parallel()
860+
861+
tempDir := t.TempDir()
862+
args := tc.setupCmd(t, tempDir)
863+
864+
pkg := packages.Server{
865+
ID: "testserver",
866+
Name: "testserver",
867+
Tools: []packages.Tool{
868+
{Name: "tool1"},
869+
},
870+
Installations: map[runtime.Runtime]packages.Installation{
871+
runtime.UVX: {
872+
Runtime: "uvx",
873+
Package: "mcp-server-testserver",
874+
Version: "latest",
875+
Recommended: true,
876+
},
877+
},
878+
}
879+
880+
cfg := &fakeConfig{}
881+
cmdObj, err := NewAddCmd(
882+
&cmd.BaseCmd{},
883+
cmdopts.WithConfigLoader(&fakeLoader{cfg: cfg}),
884+
cmdopts.WithRegistryBuilder(&fakeBuilder{reg: &fakeRegistry{pkg: pkg}}),
885+
)
886+
require.NoError(t, err)
887+
888+
cmdObj.SetOut(io.Discard)
889+
cmdObj.SetErr(io.Discard)
890+
cmdObj.SetArgs(args)
891+
892+
err = cmdObj.Execute()
893+
require.NoError(t, err)
894+
require.True(t, cfg.addCalled)
895+
require.Equal(t, "testserver", cfg.entry.Name)
896+
897+
// tempDir is available here for any cache directory verification
898+
})
899+
}
900+
}
901+
902+
func TestAddCmd_NoCacheDirectoryCreatedWhenDisabled(t *testing.T) {
903+
t.Parallel()
904+
905+
tempDir := t.TempDir()
906+
cacheSubDir := filepath.Join(tempDir, "should-not-be-created")
907+
908+
// Verify the cache directory doesn't exist initially.
909+
_, err := os.Stat(cacheSubDir)
910+
require.True(t, os.IsNotExist(err), "Cache directory should not exist initially")
911+
912+
pkg := packages.Server{
913+
ID: "testserver",
914+
Name: "testserver",
915+
Tools: []packages.Tool{
916+
{Name: "tool1"},
917+
},
918+
Installations: map[runtime.Runtime]packages.Installation{
919+
runtime.UVX: {
920+
Runtime: "uvx",
921+
Package: "mcp-server-testserver",
922+
Version: "latest",
923+
Recommended: true,
924+
},
925+
},
926+
}
927+
928+
cfg := &fakeConfig{}
929+
cmdObj, err := NewAddCmd(
930+
&cmd.BaseCmd{},
931+
cmdopts.WithConfigLoader(&fakeLoader{cfg: cfg}),
932+
cmdopts.WithRegistryBuilder(&fakeBuilder{reg: &fakeRegistry{pkg: pkg}}),
933+
)
934+
require.NoError(t, err)
935+
936+
cmdObj.SetOut(io.Discard)
937+
cmdObj.SetErr(io.Discard)
938+
// Use --no-cache with custom cache directory - directory should NOT be created.
939+
cmdObj.SetArgs([]string{"testserver", "--no-cache", "--cache-dir", cacheSubDir})
940+
941+
err = cmdObj.Execute()
942+
require.NoError(t, err)
943+
require.True(t, cfg.addCalled)
944+
require.Equal(t, "testserver", cfg.entry.Name)
945+
946+
// Verify the cache directory was never created.
947+
_, err = os.Stat(cacheSubDir)
948+
require.True(t, os.IsNotExist(err), "Cache directory should not be created when --no-cache is used")
949+
}

0 commit comments

Comments
 (0)