Skip to content

Commit 286c1cb

Browse files
authored
Merge pull request github#328 from xenoscopic/version-pinning
security: enable version pinning for local MCP servers
2 parents d80aaca + 2d73ae9 commit 286c1cb

216 files changed

Lines changed: 401 additions & 170 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ about:
6464
icon: https://avatars.githubusercontent.com/u/182288589?s=200&v=4
6565
source:
6666
project: https://github.com/myorg/my-orgdb-mcp
67+
commit: 0123456789abcdef0123456789abcdef01234567
6768
config:
6869
description: Configure the connection to TODO
6970
secrets:

cmd/build/main.go

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"strings"
1313

1414
"github.com/docker/mcp-registry/internal/mcp"
15-
"github.com/docker/mcp-registry/pkg/github"
1615
"github.com/docker/mcp-registry/pkg/servers"
1716
)
1817

@@ -117,39 +116,19 @@ func buildDockerEnv(additionalEnv ...string) []string {
117116
}
118117

119118
func buildMcpImage(ctx context.Context, server servers.Server) error {
120-
projectURL := server.Source.Project
121-
branch := server.Source.Branch
122-
directory := server.Source.Directory
123-
124-
client := github.New()
125-
126-
repository, err := client.GetProjectRepository(ctx, projectURL)
127-
if err != nil {
128-
return err
129-
}
130-
131-
if branch == "" {
132-
branch = repository.GetDefaultBranch()
119+
commit := server.Source.Commit
120+
if commit == "" {
121+
return fmt.Errorf("local server %s must specify source.commit before building", server.Name)
133122
}
134123

135-
sha, err := client.GetCommitSHA1(ctx, projectURL, branch)
136-
if err != nil {
137-
return err
138-
}
139-
140-
gitURL := projectURL + ".git#"
141-
if branch != "" {
142-
gitURL += branch
143-
}
144-
if directory != "" && directory != "." {
145-
gitURL += ":" + directory
146-
}
124+
gitURL := server.GetContext()
147125

148126
var cmd *exec.Cmd
149127
token := os.Getenv("GITHUB_TOKEN")
150128

151129
buildArgs := []string{
152-
"-f", server.GetDockerfile(), "-t", "check", "-t", server.Image, "--label", "org.opencontainers.image.revision=" + sha, "--load",
130+
"-f", server.GetDockerfile(), "-t", "check", "-t", server.Image,
131+
"--label", "org.opencontainers.image.revision=" + commit, "--load",
153132
}
154133

155134
if server.Source.BuildTarget != "" {

cmd/clean/main.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
/*
2+
Copyright © 2025 Docker, Inc.
3+
4+
Permission is hereby granted, free of charge, to any person obtaining a copy
5+
of this software and associated documentation files (the "Software"), to deal
6+
in the Software without restriction, including without limitation the rights
7+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
copies of the Software, and to permit persons to whom the Software is
9+
furnished to do so, subject to the following conditions:
10+
11+
The above copyright notice and this permission notice shall be included in
12+
all copies or substantial portions of the Software.
13+
14+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20+
THE SOFTWARE.
21+
*/
22+
123
package main
224

325
import (

cmd/create/main.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,7 @@ func run(ctx context.Context, buildURL, name, category, userProvidedImage string
128128
}
129129

130130
if build && userProvidedImage == "" {
131-
gitURL := projectURL + ".git#"
132-
if branch != "" {
133-
gitURL += branch
134-
}
131+
gitURL := projectURL + ".git#" + sha
135132
if directory != "" && directory != "." {
136133
gitURL += ":" + directory
137134
}
@@ -219,6 +216,7 @@ func run(ctx context.Context, buildURL, name, category, userProvidedImage string
219216
Project: projectURL,
220217
Upstream: upstream,
221218
Branch: branch,
219+
Commit: sha,
222220
Directory: directory,
223221
},
224222
Run: servers.Run{

cmd/validate/main.go

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ func run(name string) error {
4343
return err
4444
}
4545

46+
if err := isCommitPinnedIfNecessary(name); err != nil {
47+
return err
48+
}
49+
4650
if err := areSecretsValid(name); err != nil {
4751
return err
4852
}
@@ -72,10 +76,21 @@ func run(name string) error {
7276
return nil
7377
}
7478

79+
// legacyNameExceptions enumerates catalog entries added before current naming rules.
80+
var legacyNameExceptions = map[string]bool{
81+
"SQLite": true,
82+
"osp_marketing_tools": true,
83+
"youtube_transcript": true,
84+
}
85+
7586
// check if the name is a valid
7687
func isNameValid(name string) error {
7788
// check if name has only letters, numbers, and hyphens
7889
if !regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(name) {
90+
if legacyNameExceptions[name] {
91+
fmt.Printf("⚠️ Name %s is grandfathered and bypasses naming rules.\n", name)
92+
return nil
93+
}
7994
return fmt.Errorf("name is not valid. It must be a lowercase string with only letters, numbers, and hyphens")
8095
}
8196

@@ -104,21 +119,63 @@ func isDirectoryValid(name string) error {
104119
return nil
105120
}
106121

122+
var commitSHA1Pattern = regexp.MustCompile(`^[a-f0-9]{40}$`)
123+
124+
// isCommitPinnedIfNecessary ensures that every local server is pinned to a specific commit.
125+
func isCommitPinnedIfNecessary(name string) error {
126+
server, err := readServerYaml(name)
127+
if err != nil {
128+
return err
129+
}
130+
131+
if server.Type != "server" {
132+
fmt.Println("✅ Commit pin not required (non-local server)")
133+
return nil
134+
}
135+
136+
if server.Source.Commit == "" {
137+
return fmt.Errorf("local server must specify source.commit to pin the audited revision")
138+
}
139+
140+
if !commitSHA1Pattern.MatchString(strings.ToLower(server.Source.Commit)) {
141+
return fmt.Errorf("source.commit must be a 40-character lowercase SHA1 (got %q)", server.Source.Commit)
142+
}
143+
144+
fmt.Println("✅ Commit is pinned")
145+
return nil
146+
}
147+
148+
// secretNamePattern validates that secret names match the expected prefix.name
149+
// format requirement.
150+
var secretNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+$`)
151+
152+
// legacySecretNameExceptions enumerates secrets defined before the current
153+
// naming rules were introduced.
154+
var legacySecretNameExceptions = map[string]map[string]bool{
155+
"nasdaq-data-link": {
156+
"nasdaq_data_link_api_key": true,
157+
},
158+
"sec-edgar": {
159+
"sec_edgar_user_agent": true,
160+
},
161+
}
162+
107163
// check if the secrets are valid
108-
// secrets must be prefixed with the name of the server
109164
func areSecretsValid(name string) error {
110-
// read the server.yaml file
111165
server, err := readServerYaml(name)
112166
if err != nil {
113167
return err
114168
}
115169

116-
// check if the server.yaml file has a valid secrets
117-
if len(server.Config.Secrets) > 0 {
118-
for _, secret := range server.Config.Secrets {
119-
if !strings.HasPrefix(secret.Name, name+".") {
120-
return fmt.Errorf("secret %s is not valid. It must be prefixed with the name of the server", secret.Name)
170+
// Ensure that all secrets match the expected format. We no longer require
171+
// that the prefix matches the server name.
172+
for _, secret := range server.Config.Secrets {
173+
if !secretNamePattern.MatchString(secret.Name) {
174+
if legacySecretNameExceptions[name][secret.Name] {
175+
fmt.Printf("⚠️ Secret %s for %s is grandfathered and bypasses naming rules.\n", secret.Name, name)
176+
continue
121177
}
178+
return fmt.Errorf("secret %s is not valid. It must use prefix.name format with alphanumeric characters, hyphen, period, or underscore", secret.Name)
122179
}
123180
}
124181

@@ -182,44 +239,51 @@ func isIconValid(name string) error {
182239
}
183240

184241
if server.About.Icon == "" {
185-
fmt.Println("🛑 No icon found")
242+
fmt.Println("⚠️ No icon found")
186243
return nil
187244
}
188245
// fetch the image and check the size
189246
resp, err := http.Get(server.About.Icon)
190247
if err != nil {
191-
fmt.Println("🛑 Icon could not be fetched")
248+
fmt.Println("⚠️ Icon could not be fetched")
192249
return nil
193250
}
194251
defer resp.Body.Close()
195252

196253
if resp.StatusCode != 200 {
197-
fmt.Printf("🛑 Icon could not be fetched, status code: %d, url: %s\n", resp.StatusCode, server.About.Icon)
254+
fmt.Printf("⚠️ Icon could not be fetched, status code: %d, url: %s\n", resp.StatusCode, server.About.Icon)
198255
return nil
199256
}
200257
if resp.ContentLength > 2*1024*1024 {
201-
fmt.Println("🛑 Icon is too large. It must be less than 2MB")
258+
fmt.Println("⚠️ Icon is too large. It must be less than 2MB")
202259
return nil
203260
}
204261

205-
// Check content type for SVG support
262+
// Check content type for SVG, favicon, and WebP support
206263
contentType := resp.Header.Get("Content-Type")
207-
if contentType == "image/svg+xml" {
264+
switch contentType {
265+
case "image/svg+xml":
208266
fmt.Println("✅ Icon is valid (SVG)")
209267
return nil
268+
case "image/x-icon":
269+
fmt.Println("✅ Icon is valid (favicon)")
270+
return nil
271+
case "image/webp":
272+
fmt.Println("✅ Icon is valid (WebP)")
273+
return nil
210274
}
211275

212276
img, format, err := image.DecodeConfig(resp.Body)
213277
if err != nil {
214278
return err
215279
}
216-
if format != "png" {
217-
fmt.Println("🛑 Icon is not a png or svg. It must be a png or svg")
280+
if format != "png" && format != "jpeg" {
281+
fmt.Println("⚠️ Icon is not a png or svg. It must be a png or svg")
218282
return nil
219283
}
220284

221285
if img.Width > 512 || img.Height > 512 {
222-
fmt.Println("🛑 Icon is too large. It must be less than 512x512")
286+
fmt.Println("⚠️ Icon is too large. It must be less than 512x512")
223287
return nil
224288
}
225289

@@ -304,6 +368,11 @@ func hasValidTools(server servers.Server) error {
304368
return nil
305369
}
306370

371+
// Some special entries bypass the dynamic tools requirement.
372+
var oauthDynamicToolExceptions = map[string]bool{
373+
"github-official": true,
374+
}
375+
307376
// check if servers with OAuth have dynamic tools enabled
308377
func isOAuthDynamicValid(name string) error {
309378
server, err := readServerYaml(name)
@@ -314,7 +383,11 @@ func isOAuthDynamicValid(name string) error {
314383
// If server has OAuth configuration, it must have dynamic tools enabled
315384
if len(server.OAuth) > 0 {
316385
if server.Dynamic == nil || !server.Dynamic.Tools {
317-
return fmt.Errorf("server with OAuth must have 'dynamic: tools: true' configuration")
386+
if oauthDynamicToolExceptions[name] {
387+
fmt.Printf("⚠️ OAuth dynamic rule bypassed for %s (special configuration).\n", name)
388+
} else {
389+
return fmt.Errorf("server with OAuth must have 'dynamic: tools: true' configuration")
390+
}
318391
}
319392
}
320393

cmd/validate/main_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ func Test_isNameValid(t *testing.T) {
8888
},
8989
wantError: true,
9090
},
91+
{
92+
name: "legacy uppercase name",
93+
args: args{
94+
name: "SQLite",
95+
},
96+
wantError: false,
97+
},
98+
{
99+
name: "legacy underscore name",
100+
args: args{
101+
name: "youtube_transcript",
102+
},
103+
wantError: false,
104+
},
91105
}
92106
for _, tt := range tests {
93107
t.Run(tt.name, func(t *testing.T) {

cmd/wizard/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ type WizardData struct {
8787
ServerName string
8888
GitHubRepo string
8989
Branch string
90+
Commit string
9091
Category string
9192
Title string
9293
Description string
@@ -506,6 +507,7 @@ func generateAndSave(data *WizardData) error {
506507
},
507508
Source: servers.Source{
508509
Project: data.GitHubRepo,
510+
Commit: data.Commit,
509511
},
510512
}
511513

@@ -618,6 +620,14 @@ func validateGithubRepo(data *WizardData) error {
618620
return err
619621
}
620622

623+
sha, err := client.GetCommitSHA1(ctx, detectedInfo.ProjectURL, detectedInfo.Branch)
624+
if err != nil {
625+
return err
626+
}
627+
628+
data.GitHubRepo = detectedInfo.ProjectURL
629+
data.Commit = sha
630+
621631
parts := strings.Split(strings.ToLower(data.GitHubRepo), "/")
622632
name := parts[len(parts)-1]
623633

docs/configuration.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ run:
8585
- --transport=stdio
8686
```
8787

88+
## Source Pinning
89+
90+
Local servers must pin their source repository to a specific Git commit using the `source.commit` field. Once an initial revision is accepted into the registry, an automated nightly GitHub Action will drive PRs to perform updates.
91+
8892
## User
8993

9094
If you need to run the container with a specific user, you can do it in the `run` block. If you want the user to be able to define the container user, you will need to create a parameter first and then add the `run` block to the server.
@@ -119,6 +123,7 @@ about:
119123
icon: https://...
120124
source:
121125
project: https://github.com/my-org/my-mcp-server
126+
commit: 0123456789abcdef0123456789abcdef01234567
122127
run:
123128
command:
124129
- --transport=stdio

0 commit comments

Comments
 (0)