Skip to content
Open
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
136 changes: 136 additions & 0 deletions .github/workflows/update-pins.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Update MCP Server Pins

on:
schedule:
- cron: "0 5 * * *"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
update-pins:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Configure Git user
run: |
git config user.name "docker-mcp-bot"
git config user.email "docker-mcp-bot@users.noreply.github.com"

- name: Install Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}

- name: Update pinned commits
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
task update-pins

- name: Collect per-server patches
id: prepare
run: |
# Gather the diff for each modified server YAML and store it as an
# individual patch file so we can open one PR per server.
mkdir -p patches
changed_files=$(git status --porcelain | awk '$2 ~ /^servers\/.*\/server.yaml$/ {print $2}')
if [ -z "$changed_files" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi

server_list=()
for file in $changed_files; do
server=$(basename "$(dirname "$file")")
git diff -- "$file" > "patches/${server}.patch"
server_list+=("$server")
done

# Reset the working tree so we can apply patches one-at-a-time.
git checkout -- servers

# Expose the server list to later steps.
printf '%s\n' "${server_list[@]}" | paste -sd',' - > patches/servers.txt
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "servers=$(cat patches/servers.txt)" >> "$GITHUB_OUTPUT"

- name: Create pull requests
if: steps.prepare.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IFS=',' read -ra SERVERS <<< "${{ steps.prepare.outputs.servers }}"
for server in "${SERVERS[@]}"; do
patch="patches/${server}.patch"
if [ ! -s "$patch" ]; then
echo "No patch found for $server, skipping."
continue
fi

# Look up the new commit hash in the patch so we can decide whether
# an existing automation branch already covers it.
new_commit=$(awk '/^\+.*commit:/{print $2}' "$patch" | tail -n1)
branch="automation/update-pin-${server}"

# Start from a clean copy of main for each server so branches do not
# interfere with one another.
git checkout main
git fetch origin main
git reset --hard origin/main

# If a prior PR exists for this server, fetch it and bail out when
# the requested commit is identical (no update required).
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "$branch"
existing_commit=$(git show "origin/${branch}:servers/${server}/server.yaml" 2>/dev/null | awk '/commit:/{print $2}' | tail -n1)
if [ -n "$existing_commit" ] && [ "$existing_commit" = "$new_commit" ]; then
echo "Existing PR for $server already pins ${existing_commit}; skipping."
continue
fi
fi

# Apply the patch onto a fresh branch for this server.
git checkout -B "$branch" origin/main
if ! git apply "$patch"; then
echo "Failed to apply patch for $server, skipping."
continue
fi

if git diff --quiet; then
echo "No changes after applying patch for $server, skipping."
continue
fi

# Commit the server YAML change and force-push the automation branch.
git add "servers/${server}/server.yaml"
git commit -m "chore: update pin for ${server}"
git push --force origin "$branch"

# Create or update the PR dedicated to this server.
if gh pr view --head "$branch" >/dev/null 2>&1; then
gh pr edit "$branch" \
--title "chore: update pin for ${server}" \
--body "Automated commit pin update for ${server}."
else
gh pr create \
--title "chore: update pin for ${server}" \
--body "Automated commit pin update for ${server}." \
--base main \
--head "$branch"
fi
done

git checkout main
4 changes: 4 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ tasks:
desc: Create a new mcp server definition
cmd: go run ./cmd/create {{.CLI_ARGS}}

update-pins:
desc: Refresh server commit pins from upstream repositories
cmd: go run ./cmd/update-pins {{.CLI_ARGS}}

build:
desc: Build a server image
cmd: go run ./cmd/build {{.CLI_ARGS}}
Expand Down
187 changes: 187 additions & 0 deletions cmd/update-pins/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
Copyright © 2025 Docker, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/

package main

import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"

"github.com/docker/mcp-registry/pkg/github"
"github.com/docker/mcp-registry/pkg/servers"
)

// main orchestrates the pin refresh process, updating server definitions when
// upstream branches advance.
func main() {
ctx := context.Background()

// Enumerate the server directories that contain YAML definitions.
entries, err := os.ReadDir("servers")
if err != nil {
fmt.Fprintf(os.Stderr, "reading servers directory: %v\n", err)
os.Exit(1)
}

var updated []string
for _, entry := range entries {
// Ignore any files that are not server directories.
if !entry.IsDir() {
continue
}

serverPath := filepath.Join("servers", entry.Name(), "server.yaml")
server, err := servers.Read(serverPath)
if err != nil {
fmt.Fprintf(os.Stderr, "reading %s: %v\n", serverPath, err)
continue
}

if server.Type != "server" {
continue
}

if !strings.HasPrefix(server.Image, "mcp/") {
continue
}

if server.Source.Project == "" {
continue
}

// Only GitHub repositories are supported by the current workflow.
if !strings.Contains(server.Source.Project, "github.com/") {
fmt.Printf("Skipping %s: project is not hosted on GitHub.\n", server.Name)
continue
}

// Unpinned servers have to undergo a separate security audit first.
existing := strings.ToLower(server.Source.Commit)
if existing == "" {
fmt.Printf("Skipping %s: no pinned commit present.\n", server.Name)
continue
}

// Resolve the current branch head for comparison.
branch := server.GetBranch()
client := github.NewFromServer(server)

latest, err := client.GetCommitSHA1(ctx, server.Source.Project, branch)
if err != nil {
fmt.Fprintf(os.Stderr, "fetching commit for %s: %v\n", server.Name, err)
continue
}

latest = strings.ToLower(latest)

changed, err := writeCommit(serverPath, latest)
if err != nil {
fmt.Fprintf(os.Stderr, "updating %s: %v\n", server.Name, err)
continue
}

if existing != latest {
fmt.Printf("Updated %s: %s -> %s\n", server.Name, existing, latest)
} else if changed {
fmt.Printf("Reformatted pinned commit for %s at %s\n", server.Name, latest)
}

if changed {
updated = append(updated, server.Name)
}
if existing == latest && !changed {
continue
}
}

if len(updated) == 0 {
fmt.Println("No commit updates required.")
return
}

sort.Strings(updated)
fmt.Println("Servers with updated pins:", strings.Join(updated, ", "))
}

// writeCommit inserts or updates the commit field inside the source block of
// a server definition while preserving the surrounding formatting. The bool
// return value indicates whether the file contents were modified.
func writeCommit(path string, updated string) (bool, error) {
content, err := os.ReadFile(path)
if err != nil {
return false, err
}

lines := strings.Split(string(content), "\n")
sourceIndex := -1
for i, line := range lines {
if strings.HasPrefix(line, "source:") {
sourceIndex = i
break
}
}
if sourceIndex == -1 {
return false, fmt.Errorf("no source block found")
}

commitIndex := -1
indent := ""
commitPattern := regexp.MustCompile(`^([ \t]+)commit:\s*[a-fA-F0-9]{40}\s*$`)
for i := sourceIndex + 1; i < len(lines); i++ {
line := lines[i]
if !strings.HasPrefix(line, " ") {
break
}

if match := commitPattern.FindStringSubmatch(line); match != nil {
commitIndex = i
indent = match[1]
break
}
}

if commitIndex < 0 {
return false, fmt.Errorf("no commit line found in source block")
}

newLine := indent + "commit: " + updated
lines[commitIndex] = newLine

output := strings.Join(lines, "\n")
if !strings.HasSuffix(output, "\n") {
output += "\n"
}

if output == string(content) {
return false, nil
}

if err := os.WriteFile(path, []byte(output), 0o644); err != nil {
return false, err
}
return true, nil
}