Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ jobs:
CONTAINER_COUNT=$(jq '.servers | length' build/toolhive/registry.json)
REMOTE_COUNT=$(jq '.remote_servers | length // 0' build/toolhive/registry.json)
TOTAL=$((CONTAINER_COUNT + REMOTE_COUNT))
SKILL_COUNT=$(jq '.data.skills | length // 0' build/toolhive/official-registry.json)
SIZE=$(du -h build/toolhive/registry.json | cut -f1)
LAST_UPDATED=$(jq -r '.last_updated' build/toolhive/registry.json)

Expand All @@ -197,6 +198,7 @@ jobs:
echo "- **Total Servers**: $TOTAL"
echo " - Container-based: $CONTAINER_COUNT"
echo " - Remote: $REMOTE_COUNT"
echo "- **Skills**: $SKILL_COUNT"
echo "- **File Size**: $SIZE"
echo "- **Last Updated**: $LAST_UPDATED"
echo "EOF"
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,18 @@ jobs:
TOTAL=$((CONTAINER_COUNT + REMOTE_COUNT))
LAST_UPDATED=$(jq -r '.last_updated' "$data_file")

# Count skills from the upstream format
upstream_file="$registry_dir/data/official-registry.json"
if [ -f "$upstream_file" ]; then
SKILL_COUNT=$(jq '.data.skills | length // 0' "$upstream_file")
else
SKILL_COUNT=0
fi

echo "${registry_name}_total=$TOTAL" >> $GITHUB_OUTPUT
echo "${registry_name}_container=$CONTAINER_COUNT" >> $GITHUB_OUTPUT
echo "${registry_name}_remote=$REMOTE_COUNT" >> $GITHUB_OUTPUT
echo "${registry_name}_skills=$SKILL_COUNT" >> $GITHUB_OUTPUT
echo "${registry_name}_last_updated=$LAST_UPDATED" >> $GITHUB_OUTPUT
done

Expand All @@ -63,6 +72,7 @@ jobs:
| **Total Servers** | ${{ steps.stats.outputs.toolhive_total }} |
| **Container-based** | ${{ steps.stats.outputs.toolhive_container }} |
| **Remote** | ${{ steps.stats.outputs.toolhive_remote }} |
| **Skills** | ${{ steps.stats.outputs.toolhive_skills }} |

### Usage

Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# toolhive-catalog

Curated registry of MCP (Model Context Protocol) servers for ToolHive.
Curated registry of MCP (Model Context Protocol) servers and skills for ToolHive.

## Skills

Expand All @@ -24,6 +24,11 @@ Each server lives at `registries/toolhive/servers/<name>/` containing:
- `server.json` — server definition (see `docs/adding-entries-llm.md` for full schema)
- `icon.svg` — server icon

Each skill lives at `registries/toolhive/skills/<name>/` containing:
- `skill.json` — skill definition (uses `Skill` type from toolhive-core)
- `SKILL.md` — skill prompt with YAML frontmatter (name, description, version, allowed-tools)
- `icon.svg` — skill icon

## Non-obvious gotchas

- The `_meta` extension key must exactly match `packages[0].identifier` or `remotes[0].url`
Expand Down
98 changes: 93 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# ToolHive Catalog

This repository contains the catalog of MCP (Model Context Protocol) servers available for ToolHive. Each server entry provides AI assistants with specialized tools and capabilities.
This repository contains the catalog of MCP (Model Context Protocol) servers and skills available for ToolHive. Each server entry provides AI assistants with specialized tools and capabilities, while skills provide reusable prompts and workflows that leverage those tools.

## What is this?

Think of this as a catalog of tools that AI assistants can use. Each entry in this registry represents a server that provides specific capabilities—like interacting with GitHub, querying databases, or fetching web content.
Think of this as a catalog of tools and skills that AI assistants can use. Each server entry represents a server that provides specific capabilities—like interacting with GitHub, querying databases, or fetching web content. Each skill entry is a reusable prompt or workflow that combines server tools to accomplish a specific task—like reviewing pull requests or debugging issues.

## How to Add Your MCP Server

Expand All @@ -21,8 +21,13 @@ Create a new folder in `registries/toolhive/servers/` with your server's name (u
registries/
└── toolhive/
└── servers/
└── my-awesome-server/
└── server.json
│ └── my-awesome-server/
│ └── server.json
└── skills/
└── my-skill/
├── skill.json
├── SKILL.md
└── icon.svg
```

### Step 2: Create Your server.json File
Expand Down Expand Up @@ -153,6 +158,88 @@ You can add more information in the `_meta` extensions block:
}
```

## How to Add a Skill

Skills are reusable prompts and workflows that leverage MCP server tools. A skill is defined by a `SKILL.md` file (with YAML frontmatter) and a `skill.json` registry entry.

### Step 1: Create a Folder

Create a new folder in `registries/toolhive/skills/` with your skill's name:

```bash
mkdir -p registries/toolhive/skills/my-skill
```

### Step 2: Create the SKILL.md File

This is the actual skill content. It uses YAML frontmatter for metadata and markdown for the prompt/instructions:

```markdown
---
name: my-skill
description: What this skill does in one sentence.
version: "0.1.0"
allowed-tools:
- server/tool_name_1
- server/tool_name_2
license: Apache-2.0
metadata:
author: Your Name
---

# My Skill

Instructions and prompts for the AI assistant go here...
```

### Step 3: Create the skill.json File

This registers the skill in the catalog:

```json
{
"namespace": "io.github.stacklok",
"name": "my-skill",
"title": "My Skill",
"description": "What this skill does in one sentence.",
"version": "0.1.0",
"status": "active",
"license": "Apache-2.0",
"allowedTools": ["server/tool_name_1", "server/tool_name_2"],
"repository": {
"url": "https://github.com/stacklok/toolhive-catalog",
"type": "git"
},
"icons": [
{
"src": "icon.svg",
"type": "image/svg+xml"
}
],
"packages": [
{
"registryType": "git",
"url": "https://github.com/stacklok/toolhive-catalog",
"ref": "main",
"subfolder": "registries/toolhive/skills/my-skill"
}
]
}
```

### Step 4: Add an Icon

Add an `icon.svg` file to your skill directory.

### Step 5: Validate

```bash
task catalog:validate
task catalog:build
```

For a complete example, see `registries/toolhive/skills/code-review/`.

## Common Questions

### What is "transport"?
Expand Down Expand Up @@ -254,7 +341,8 @@ For **remote servers**:

## Need Help?

- Check existing entries in `registries/toolhive/servers/` for examples
- Check existing entries in `registries/toolhive/servers/` for server examples
- Check existing entries in `registries/toolhive/skills/` for skill examples
- Open an issue if you have questions
- Join our community discussions

Expand Down
54 changes: 42 additions & 12 deletions cmd/catalog/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const (
// Each registry is expected to have a "servers" subdirectory containing
// individual server directories with server.json files.
serversSubdir = "servers"

// Registries may optionally have a "skills" subdirectory containing
// individual skill directories with skill.json files.
skillsSubdir = "skills"
)

var (
Expand Down Expand Up @@ -109,10 +113,11 @@ func main() {
}
}

// registryInfo holds the name and loader for a discovered registry.
// registryInfo holds the name and loaders for a discovered registry.
type registryInfo struct {
name string
loader *internalregistry.Loader
name string
loader *internalregistry.Loader
skillLoader *internalregistry.SkillLoader
}

// discoverRegistries walks the registries root directory, finds subdirectories
Expand Down Expand Up @@ -143,13 +148,27 @@ func discoverRegistries() ([]registryInfo, error) {
return nil, fmt.Errorf("failed to load registry %q: %w", entry.Name(), err)
}

// Optionally load skills if the skills subdirectory exists
var skillLoader *internalregistry.SkillLoader
skillsPath := filepath.Join(registriesDir, entry.Name(), skillsSubdir)
if info, serr := os.Stat(skillsPath); serr == nil && info.IsDir() {
skillLoader = internalregistry.NewSkillLoader(skillsPath)
if err := skillLoader.LoadAll(); err != nil {
return nil, fmt.Errorf("failed to load skills for registry %q: %w", entry.Name(), err)
}
if verbose {
fmt.Printf(" loaded %d skills\n", len(skillLoader.GetEntries()))
}
}

if verbose {
fmt.Printf("Discovered registry %q with %d entries\n", entry.Name(), len(loader.GetEntries()))
}

registries = append(registries, registryInfo{
name: entry.Name(),
loader: loader,
name: entry.Name(),
loader: loader,
skillLoader: skillLoader,
})
}

Expand All @@ -175,7 +194,7 @@ func runBuild(_ *cobra.Command, _ []string) error {
}

for _, f := range formats {
if err := buildFormat(reg.loader, f, regOutputDir); err != nil {
if err := buildFormat(reg, f, regOutputDir); err != nil {
return fmt.Errorf("failed to build %s format for registry %q: %w", f, reg.name, err)
}
}
Expand All @@ -195,6 +214,9 @@ func runValidate(_ *cobra.Command, _ []string) error {

for _, reg := range registries {
upstreamBuilder := internalregistry.NewBuilder(reg.loader)
if reg.skillLoader != nil {
upstreamBuilder.WithSkillLoader(reg.skillLoader)
}
if err := upstreamBuilder.ValidateAgainstSchema(); err != nil {
return fmt.Errorf("registry %q: upstream validation failed: %w", reg.name, err)
}
Expand All @@ -210,7 +232,12 @@ func runValidate(_ *cobra.Command, _ []string) error {
fmt.Printf(" %s toolhive format: valid\n", reg.name)
}

fmt.Printf("Registry %q: all %d entries valid (both formats)\n", reg.name, len(reg.loader.GetEntries()))
skillCount := 0
if reg.skillLoader != nil {
skillCount = len(reg.skillLoader.GetEntries())
}
fmt.Printf("Registry %q: all %d servers and %d skills valid (both formats)\n",
reg.name, len(reg.loader.GetEntries()), skillCount)
}

return nil
Expand All @@ -229,12 +256,12 @@ func determineFormats(f string) []string {
}
}

func buildFormat(loader *internalregistry.Loader, f string, outDir string) error {
func buildFormat(reg registryInfo, f string, outDir string) error {
switch f {
case formatToolhive:
return buildToolhive(loader, outDir)
return buildToolhive(reg.loader, outDir)
case formatUpstream:
return buildUpstream(loader, outDir)
return buildUpstream(reg, outDir)
default:
return fmt.Errorf("unknown format: %s", f)
}
Expand All @@ -254,8 +281,11 @@ func buildToolhive(loader *internalregistry.Loader, outDir string) error {
return nil
}

func buildUpstream(loader *internalregistry.Loader, outDir string) error {
builder := internalregistry.NewBuilder(loader)
func buildUpstream(reg registryInfo, outDir string) error {
builder := internalregistry.NewBuilder(reg.loader)
if reg.skillLoader != nil {
builder.WithSkillLoader(reg.skillLoader)
}
outPath := filepath.Join(outDir, "official-registry.json")

if err := builder.WriteJSON(outPath); err != nil {
Expand Down
Loading