Skip to content

Commit aeb5363

Browse files
committed
Implement catalog builder CLI
Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
1 parent 1bcae73 commit aeb5363

File tree

10 files changed

+278
-15
lines changed

10 files changed

+278
-15
lines changed

cmd/catalog/main.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Package main provides the catalog CLI tool for building registry files
2+
// from server.json entries. It discovers registries under a root directory
3+
// and produces output for each one.
4+
package main
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
12+
"github.com/spf13/cobra"
13+
14+
internalregistry "github.com/stacklok/toolhive-registry/internal/registry"
15+
)
16+
17+
const (
18+
formatToolhive = "toolhive"
19+
formatUpstream = "upstream"
20+
formatAll = "all"
21+
22+
defaultRegistries = "registries"
23+
defaultOutputDir = "build"
24+
25+
// Each registry is expected to have a "servers" subdirectory containing
26+
// individual server directories with server.json files.
27+
serversSubdir = "servers"
28+
)
29+
30+
var (
31+
version = "dev"
32+
commit = "unknown"
33+
date = "unknown"
34+
)
35+
36+
var (
37+
registriesDir string
38+
outputDir string
39+
format string
40+
verbose bool
41+
)
42+
43+
var rootCmd = &cobra.Command{
44+
Use: "catalog",
45+
Short: "Build the ToolHive catalog from server.json files",
46+
Long: `catalog discovers registries under a root directory and builds
47+
registry files from individual server.json entries for each one.
48+
49+
Given a registries directory (default: registries/), it looks for
50+
subdirectories containing a "servers/" folder with server.json files:
51+
52+
registries/
53+
toolhive/
54+
servers/
55+
github/server.json
56+
...
57+
58+
For each registry found, it produces output in the build directory:
59+
60+
build/
61+
toolhive/
62+
registry.json
63+
official-registry.json`,
64+
}
65+
66+
var buildCmd = &cobra.Command{
67+
Use: "build",
68+
Short: "Build registry files for all discovered registries",
69+
RunE: runBuild,
70+
}
71+
72+
var validateCmd = &cobra.Command{
73+
Use: "validate",
74+
Short: "Validate all server.json entries across all registries",
75+
RunE: runValidate,
76+
}
77+
78+
var versionCmd = &cobra.Command{
79+
Use: "version",
80+
Short: "Print version information",
81+
Run: func(*cobra.Command, []string) {
82+
fmt.Printf("catalog %s\n", version)
83+
fmt.Printf(" commit: %s\n", commit)
84+
fmt.Printf(" built: %s\n", date)
85+
},
86+
}
87+
88+
func init() {
89+
rootCmd.PersistentFlags().StringVarP(&registriesDir, "registries", "r", defaultRegistries, "Path to the registries root directory")
90+
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output")
91+
92+
buildCmd.Flags().StringVarP(&outputDir, "output-dir", "o", defaultOutputDir, "Output directory")
93+
buildCmd.Flags().StringVarP(&format, "format", "f", formatAll,
94+
fmt.Sprintf("Output format (%s, %s, %s)", formatToolhive, formatUpstream, formatAll))
95+
96+
rootCmd.AddCommand(buildCmd)
97+
rootCmd.AddCommand(validateCmd)
98+
rootCmd.AddCommand(versionCmd)
99+
}
100+
101+
func main() {
102+
if err := rootCmd.Execute(); err != nil {
103+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
104+
os.Exit(1)
105+
}
106+
}
107+
108+
// registryInfo holds the name and loader for a discovered registry.
109+
type registryInfo struct {
110+
name string
111+
loader *internalregistry.Loader
112+
}
113+
114+
// discoverRegistries walks the registries root directory, finds subdirectories
115+
// that contain a "servers/" folder, and returns a loader for each.
116+
func discoverRegistries() ([]registryInfo, error) {
117+
entries, err := os.ReadDir(registriesDir)
118+
if err != nil {
119+
return nil, fmt.Errorf("failed to read registries directory %s: %w", registriesDir, err)
120+
}
121+
122+
var registries []registryInfo
123+
for _, entry := range entries {
124+
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
125+
continue
126+
}
127+
128+
serversPath := filepath.Join(registriesDir, entry.Name(), serversSubdir)
129+
info, err := os.Stat(serversPath)
130+
if err != nil || !info.IsDir() {
131+
if verbose {
132+
fmt.Printf("Skipping %s (no %s/ directory)\n", entry.Name(), serversSubdir)
133+
}
134+
continue
135+
}
136+
137+
loader := internalregistry.NewLoader(serversPath)
138+
if err := loader.LoadAll(); err != nil {
139+
return nil, fmt.Errorf("failed to load registry %q: %w", entry.Name(), err)
140+
}
141+
142+
if verbose {
143+
fmt.Printf("Discovered registry %q with %d entries\n", entry.Name(), len(loader.GetEntries()))
144+
}
145+
146+
registries = append(registries, registryInfo{
147+
name: entry.Name(),
148+
loader: loader,
149+
})
150+
}
151+
152+
if len(registries) == 0 {
153+
return nil, fmt.Errorf("no registries found under %s", registriesDir)
154+
}
155+
156+
return registries, nil
157+
}
158+
159+
func runBuild(_ *cobra.Command, _ []string) error {
160+
registries, err := discoverRegistries()
161+
if err != nil {
162+
return err
163+
}
164+
165+
formats := determineFormats(format)
166+
167+
for _, reg := range registries {
168+
regOutputDir := filepath.Join(outputDir, reg.name)
169+
if err := os.MkdirAll(regOutputDir, 0750); err != nil {
170+
return fmt.Errorf("failed to create output directory %s: %w", regOutputDir, err)
171+
}
172+
173+
for _, f := range formats {
174+
if err := buildFormat(reg.loader, f, regOutputDir); err != nil {
175+
return fmt.Errorf("failed to build %s format for registry %q: %w", f, reg.name, err)
176+
}
177+
}
178+
179+
fmt.Printf("Built registry %q: %d entries [%s] -> %s\n",
180+
reg.name, len(reg.loader.GetEntries()), strings.Join(formats, ", "), regOutputDir)
181+
}
182+
183+
return nil
184+
}
185+
186+
func runValidate(_ *cobra.Command, _ []string) error {
187+
registries, err := discoverRegistries()
188+
if err != nil {
189+
return err
190+
}
191+
192+
for _, reg := range registries {
193+
upstreamBuilder := internalregistry.NewBuilder(reg.loader)
194+
if err := upstreamBuilder.ValidateAgainstSchema(); err != nil {
195+
return fmt.Errorf("registry %q: upstream validation failed: %w", reg.name, err)
196+
}
197+
if verbose {
198+
fmt.Printf(" %s upstream format: valid\n", reg.name)
199+
}
200+
201+
legacyBuilder := internalregistry.NewLegacyBuilder(reg.loader)
202+
if err := legacyBuilder.ValidateAgainstSchema(); err != nil {
203+
return fmt.Errorf("registry %q: toolhive validation failed: %w", reg.name, err)
204+
}
205+
if verbose {
206+
fmt.Printf(" %s toolhive format: valid\n", reg.name)
207+
}
208+
209+
fmt.Printf("Registry %q: all %d entries valid (both formats)\n", reg.name, len(reg.loader.GetEntries()))
210+
}
211+
212+
return nil
213+
}
214+
215+
func determineFormats(f string) []string {
216+
switch strings.ToLower(f) {
217+
case formatAll:
218+
return []string{formatToolhive, formatUpstream}
219+
case formatUpstream:
220+
return []string{formatUpstream}
221+
case formatToolhive:
222+
return []string{formatToolhive}
223+
default:
224+
return []string{formatAll}
225+
}
226+
}
227+
228+
func buildFormat(loader *internalregistry.Loader, f string, outDir string) error {
229+
switch f {
230+
case formatToolhive:
231+
return buildToolhive(loader, outDir)
232+
case formatUpstream:
233+
return buildUpstream(loader, outDir)
234+
default:
235+
return fmt.Errorf("unknown format: %s", f)
236+
}
237+
}
238+
239+
func buildToolhive(loader *internalregistry.Loader, outDir string) error {
240+
builder := internalregistry.NewLegacyBuilder(loader)
241+
outPath := filepath.Join(outDir, "registry.json")
242+
243+
if err := builder.WriteJSON(outPath); err != nil {
244+
return fmt.Errorf("failed to write toolhive registry: %w", err)
245+
}
246+
247+
if verbose {
248+
fmt.Printf(" wrote %s\n", outPath)
249+
}
250+
return nil
251+
}
252+
253+
func buildUpstream(loader *internalregistry.Loader, outDir string) error {
254+
builder := internalregistry.NewBuilder(loader)
255+
outPath := filepath.Join(outDir, "official-registry.json")
256+
257+
if err := builder.WriteJSON(outPath); err != nil {
258+
return fmt.Errorf("failed to write upstream registry: %w", err)
259+
}
260+
261+
if verbose {
262+
fmt.Printf(" wrote %s\n", outPath)
263+
}
264+
return nil
265+
}

registries/toolhive/servers/azure/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@
5454
"io.github.stacklok": {
5555
"mcr.microsoft.com/azure-sdk/azure-mcp:1.0.1": {
5656
"metadata": {
57-
"last_updated": "2026-01-25T13:39:48Z",
57+
"last_updated": "2026-02-16T03:01:21Z",
5858
"pulls": 1809,
59-
"stars": 1200
59+
"stars": 1201
6060
},
6161
"permissions": {
6262
"network": {

registries/toolhive/servers/buildkite/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"packages": [
1212
{
1313
"registryType": "oci",
14-
"identifier": "ghcr.io/buildkite/buildkite-mcp-server:0.9.0",
14+
"identifier": "ghcr.io/buildkite/buildkite-mcp-server:0.10.0",
1515
"transport": {
1616
"type": "stdio"
1717
},
@@ -32,7 +32,7 @@
3232
"_meta": {
3333
"io.modelcontextprotocol.registry/publisher-provided": {
3434
"io.github.stacklok": {
35-
"ghcr.io/buildkite/buildkite-mcp-server:0.9.0": {
35+
"ghcr.io/buildkite/buildkite-mcp-server:0.10.0": {
3636
"args": [
3737
"stdio"
3838
],

registries/toolhive/servers/database-toolbox/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
"0.0.0.0"
2828
],
2929
"metadata": {
30-
"last_updated": "2026-01-25T13:39:49Z",
30+
"last_updated": "2026-02-16T03:01:21Z",
3131
"pulls": 2408,
32-
"stars": 12610
32+
"stars": 12997
3333
},
3434
"permissions": {
3535
"network": {

registries/toolhive/servers/genai-toolbox/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
"io.github.stacklok": {
2424
"us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:0.27.0": {
2525
"metadata": {
26-
"last_updated": "2026-01-25T13:39:50Z",
26+
"last_updated": "2026-02-16T03:01:21Z",
2727
"pulls": 2408,
28-
"stars": 12610
28+
"stars": 12997
2929
},
3030
"permissions": {
3131
"network": {

registries/toolhive/servers/mcp-neo4j-memory/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040
"io.github.stacklok": {
4141
"ghcr.io/stacklok/dockyard/uvx/mcp-neo4j-memory:0.4.4": {
4242
"metadata": {
43-
"last_updated": "2026-01-25T13:39:47Z",
43+
"last_updated": "2026-02-16T03:01:20Z",
4444
"pulls": 105,
45-
"stars": 881
45+
"stars": 899
4646
},
4747
"permissions": {
4848
"network": {

registries/toolhive/servers/mcp-redfish/server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"packages": [
1212
{
1313
"registryType": "oci",
14-
"identifier": "ghcr.io/nokia/mcp-redfish:0.3.3",
14+
"identifier": "ghcr.io/nokia/mcp-redfish:0.3.4",
1515
"transport": {
1616
"type": "stdio"
1717
},
@@ -62,7 +62,7 @@
6262
"_meta": {
6363
"io.modelcontextprotocol.registry/publisher-provided": {
6464
"io.github.stacklok": {
65-
"ghcr.io/nokia/mcp-redfish:0.3.3": {
65+
"ghcr.io/nokia/mcp-redfish:0.3.4": {
6666
"metadata": {
6767
"last_updated": "2026-01-30T02:55:47Z",
6868
"stars": 4

registries/toolhive/servers/toolhive-doc-mcp/server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"io.github.stacklok": {
4545
"ghcr.io/stackloklabs/toolhive-doc-mcp:0.0.9": {
4646
"metadata": {
47-
"last_updated": "2026-01-25T13:39:48Z",
47+
"last_updated": "2026-02-16T03:01:21Z",
4848
"stars": 3
4949
},
5050
"status": "Active",

registry/playwright/spec.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ tags:
4747
- web
4848
- accessibility
4949
image: mcr.microsoft.com/playwright/mcp:v0.0.68
50-
target_port: 8931
5150
permissions:
5251
network:
5352
outbound:

registry/semgrep/spec.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ metadata:
2424
pulls: 21632974
2525
last_updated: "2026-02-05T04:47:25Z"
2626
repository_url: https://github.com/semgrep/semgrep
27-
target_port: 8000
2827
tags:
2928
- security
3029
- static-analysis

0 commit comments

Comments
 (0)