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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,15 @@ MCP_REGISTRY_OIDC_EXTRA_CLAIMS=[{"hd":"modelcontextprotocol.io"}]
# Grant admin permissions to OIDC-authenticated users
MCP_REGISTRY_OIDC_EDIT_PERMISSIONS=*
MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS=*

# Rate Limiting Configuration
# Enable/disable rate limiting for publish operations
MCP_REGISTRY_RATE_LIMIT_ENABLED=true

# Maximum number of servers a user can publish per day
MCP_REGISTRY_RATE_LIMIT_PER_DAY=10

# Comma-separated list of authenticated users (auth subjects) exempt from rate limiting
# Supports wildcards: anthropic/* to exempt all users under anthropic domain
# Examples: modelcontextprotocol, anthropic/*, specific-username
MCP_REGISTRY_RATE_LIMIT_EXEMPTIONS=
24 changes: 24 additions & 0 deletions docs/guides/administration/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,27 @@ export SERVER_ID="<server-uuid>"
```

This soft deletes the server. If you need to delete the content of a server (usually only where legally necessary), use the edit workflow above to scrub it all.

## Rate Limiting Configuration

The registry enforces daily publish rate limits to prevent abuse:

### Environment Variables

- `MCP_REGISTRY_RATE_LIMIT_ENABLED`: Enable/disable rate limiting (default: true)
- `MCP_REGISTRY_RATE_LIMIT_PER_DAY`: Maximum publishes per user per day (default: 10)
- `MCP_REGISTRY_RATE_LIMIT_EXEMPTIONS`: Comma-separated list of exempt users or patterns

### Exemption Patterns

Exemptions support wildcard patterns:
- Exact match: `anthropic` (exempts user "anthropic")
- Wildcard: `anthropic/*` (exempts "anthropic", "anthropic.claude", etc.)
- Multiple exemptions: `anthropic/*,modelcontextprotocol,github/*`

### Notes

- Rate limits are per authenticated user (not per namespace)
- Users with global admin permissions automatically bypass rate limits
- Limits reset on a rolling 24-hour window
- The counter is stored in the `publish_attempts` database table
3 changes: 3 additions & 0 deletions docs/guides/publishing/publish-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,9 @@ With authentication complete, publish your server:
mcp-publisher publish
```

> [!NOTE]
> **Rate Limits**: The registry enforces a limit of 10 publishes per user per day to prevent abuse. If you exceed this limit, you'll receive an error message with your current count. If you need a higher limit for legitimate use cases, please [open an issue](https://github.com/modelcontextprotocol/registry/issues).
You'll see output like:
```
✓ Successfully published
Expand Down
20 changes: 19 additions & 1 deletion docs/reference/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ Yes, extensions under the `x-publisher` property are preserved when publishing t

At time of last update, this was open for discussion in [#104](https://github.com/modelcontextprotocol/registry/issues/104).

### What are the rate limits for publishing?

The registry enforces daily rate limits to prevent abuse:

- **Default limit**: 10 publishes per authenticated user per day (rolling 24-hour window)
- **Who is affected**: All users except those with global admin permissions
- **What counts**: Each successful publish counts toward your daily limit
- **Exemptions**: Specific users or organizations can be exempted from rate limiting
- **Error message**: If you exceed the limit, you'll receive an error with your current count

If you need a higher limit for legitimate use cases, please open an issue at https://github.com/modelcontextprotocol/registry/issues

### Can I publish a private server?

Private servers are those that are only accessible to a narrow set of users. For example, servers published on a private network (like `mcp.acme-corp.internal`) or on private package registries (e.g. `npx -y @acme/mcp --registry https://artifactory.acme-corp.internal/npm`).
Expand Down Expand Up @@ -118,9 +130,15 @@ The MVP delegates security scanning to:
- Namespace authentication requirements
- Character limits and regex validation on free-form fields
- Manual takedown of spam or malicious servers
- Daily publish rate limiting per authenticated user (10 publishes per day by default)

The rate limiting system:
- Limits are per authenticated user (not per namespace)
- Default limit is 10 publishes per 24-hour period
- Administrators with global permissions bypass rate limits
- Specific users or patterns can be exempted from rate limiting

In future we might explore:
- Stricter rate limiting (e.g., 10 new servers per user per day)
- Potential AI-based spam detection
- Community reporting and admin blacklisting capabilities

Expand Down
6 changes: 3 additions & 3 deletions internal/api/handlers/v0/edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestEditServerEndpoint(t *testing.T) {
},
Version: "1.0.0",
}
published, err := registryService.Publish(testServer)
published, err := registryService.Publish(testServer, "testuser", false)
assert.NoError(t, err)
assert.NotNil(t, published)
assert.NotNil(t, published.Meta)
Expand All @@ -56,7 +56,7 @@ func TestEditServerEndpoint(t *testing.T) {
},
Version: "1.0.0",
}
otherPublished, err := registryService.Publish(otherServer)
otherPublished, err := registryService.Publish(otherServer, "testuser", false)
assert.NoError(t, err)
assert.NotNil(t, otherPublished)
assert.NotNil(t, otherPublished.Meta)
Expand All @@ -76,7 +76,7 @@ func TestEditServerEndpoint(t *testing.T) {
},
Version: "1.0.0",
}
deletedPublished, err := registryService.Publish(deletedServer)
deletedPublished, err := registryService.Publish(deletedServer, "testuser", false)
assert.NoError(t, err)
assert.NotNil(t, deletedPublished)
assert.NotNil(t, deletedPublished.Meta)
Expand Down
11 changes: 10 additions & 1 deletion internal/api/handlers/v0/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,17 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
return nil, huma.Error403Forbidden(buildPermissionErrorMessage(input.Body.Name, claims.Permissions))
}

// Check if user has global permissions (admin)
hasGlobalPermissions := false
for _, perm := range claims.Permissions {
if perm.ResourcePattern == "*" {
hasGlobalPermissions = true
break
}
}

// Publish the server with extensions
publishedServer, err := registry.Publish(input.Body)
publishedServer, err := registry.Publish(input.Body, claims.AuthMethodSubject, hasGlobalPermissions)
if err != nil {
return nil, huma.Error400BadRequest("Failed to publish server", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handlers/v0/publish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func TestPublishEndpoint(t *testing.T) {
ID: "example/test-server-existing",
},
}
_, _ = registry.Publish(existingServer)
_, _ = registry.Publish(existingServer, "testuser", false)
},
expectedStatus: http.StatusBadRequest,
expectedError: "invalid version: cannot publish duplicate version",
Expand Down
32 changes: 16 additions & 16 deletions internal/api/handlers/v0/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ func TestServersListEndpoint(t *testing.T) {
},
Version: "2.0.0",
}
_, _ = registry.Publish(server1)
_, _ = registry.Publish(server2)
_, _ = registry.Publish(server1, "testuser", false)
_, _ = registry.Publish(server2, "testuser", false)
},
expectedStatus: http.StatusOK,
},
Expand All @@ -71,7 +71,7 @@ func TestServersListEndpoint(t *testing.T) {
},
Version: "1.5.0",
}
_, _ = registry.Publish(server)
_, _ = registry.Publish(server, "testuser", false)
},
expectedStatus: http.StatusOK,
},
Expand Down Expand Up @@ -141,8 +141,8 @@ func TestServersListEndpoint(t *testing.T) {
},
Version: "1.0.0",
}
_, _ = registry.Publish(server1)
_, _ = registry.Publish(server2)
_, _ = registry.Publish(server1, "testuser", false)
_, _ = registry.Publish(server2, "testuser", false)
},
expectedStatus: http.StatusOK,
},
Expand All @@ -160,7 +160,7 @@ func TestServersListEndpoint(t *testing.T) {
},
Version: "1.0.0",
}
_, _ = registry.Publish(server)
_, _ = registry.Publish(server, "testuser", false)
},
expectedStatus: http.StatusOK,
},
Expand Down Expand Up @@ -188,8 +188,8 @@ func TestServersListEndpoint(t *testing.T) {
},
Version: "2.0.0",
}
_, _ = registry.Publish(server1)
_, _ = registry.Publish(server2) // This will be marked as latest
_, _ = registry.Publish(server1, "testuser", false)
_, _ = registry.Publish(server2, "testuser", false) // This will be marked as latest
},
expectedStatus: http.StatusOK,
},
Expand Down Expand Up @@ -217,8 +217,8 @@ func TestServersListEndpoint(t *testing.T) {
},
Version: "1.0.0",
}
_, _ = registry.Publish(server1)
_, _ = registry.Publish(server2)
_, _ = registry.Publish(server1, "testuser", false)
_, _ = registry.Publish(server2, "testuser", false)
},
expectedStatus: http.StatusOK,
},
Expand Down Expand Up @@ -274,10 +274,10 @@ func TestServersListEndpoint(t *testing.T) {
},
Version: "3.0.0",
}
_, _ = registry.Publish(server1v1)
_, _ = registry.Publish(server1v2)
_, _ = registry.Publish(server2)
_, _ = registry.Publish(server3)
_, _ = registry.Publish(server1v1, "testuser", false)
_, _ = registry.Publish(server1v2, "testuser", false)
_, _ = registry.Publish(server2, "testuser", false)
_, _ = registry.Publish(server3, "testuser", false)
},
expectedStatus: http.StatusOK,
},
Expand Down Expand Up @@ -384,7 +384,7 @@ func TestServersDetailEndpoint(t *testing.T) {
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
})
}, "testuser", false)
assert.NoError(t, err)

testCases := []struct {
Expand Down Expand Up @@ -472,7 +472,7 @@ func TestServersEndpointsIntegration(t *testing.T) {
Version: "1.0.0",
}

published, err := registryService.Publish(testServer)
published, err := registryService.Publish(testServer, "testuser", false)
assert.NoError(t, err)
assert.NotNil(t, published)

Expand Down
2 changes: 1 addition & 1 deletion internal/api/handlers/v0/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestPrometheusHandler(t *testing.T) {
ID: "example/test-server",
},
Version: "2.0.0",
})
}, "testuser", false)
assert.NoError(t, err)

cfg := config.NewConfig()
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ type Config struct {
OIDCExtraClaims string `env:"OIDC_EXTRA_CLAIMS" envDefault:""`
OIDCEditPerms string `env:"OIDC_EDIT_PERMISSIONS" envDefault:""`
OIDCPublishPerms string `env:"OIDC_PUBLISH_PERMISSIONS" envDefault:""`

// Rate Limiting Configuration
RateLimitEnabled bool `env:"RATE_LIMIT_ENABLED" envDefault:"true"`
RateLimitPerDay int `env:"RATE_LIMIT_PER_DAY" envDefault:"10"`
RateLimitExemptions string `env:"RATE_LIMIT_EXEMPTIONS" envDefault:""` // comma-separated
}

// NewConfig creates a new configuration with default values
Expand Down
7 changes: 7 additions & 0 deletions internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ type Database interface {
UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error)
// Close closes the database connection
Close() error

// Rate limiting methods
IncrementPublishCount(ctx context.Context, authMethodSubject string) error
GetPublishCount(ctx context.Context, authMethodSubject string, date time.Time) (int, error)
// CheckAndIncrementPublishCount atomically checks if the count is under the limit and increments if so
// Returns the current count and whether the increment was successful
CheckAndIncrementPublishCount(ctx context.Context, authMethodSubject string, limit int) (currentCount int, incrementSuccessful bool, err error)
}

// ConnectionType represents the type of database connection
Expand Down
61 changes: 58 additions & 3 deletions internal/database/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@ import (
"sort"
"strings"
"sync"
"time"

apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)

// MemoryDB is an in-memory implementation of the Database interface
type MemoryDB struct {
entries map[string]*apiv0.ServerJSON // maps registry metadata ID to ServerJSON
mu sync.RWMutex
entries map[string]*apiv0.ServerJSON // maps registry metadata ID to ServerJSON
publishAttempts map[string]map[string]int // authMethodSubject -> date -> count
mu sync.RWMutex
}

func NewMemoryDB() *MemoryDB {
// Convert input ServerJSON entries to have proper metadata
serverRecords := make(map[string]*apiv0.ServerJSON)
return &MemoryDB{
entries: serverRecords,
entries: serverRecords,
publishAttempts: make(map[string]map[string]int),
}
}

Expand Down Expand Up @@ -139,6 +142,58 @@ func (db *MemoryDB) UpdateServer(ctx context.Context, id string, server *apiv0.S
return server, nil
}

// IncrementPublishCount increments the publish count for an authenticated user today
func (db *MemoryDB) IncrementPublishCount(_ context.Context, authMethodSubject string) error {
db.mu.Lock()
defer db.mu.Unlock()

today := time.Now().Format(time.DateOnly)
if db.publishAttempts[authMethodSubject] == nil {
db.publishAttempts[authMethodSubject] = make(map[string]int)
}
db.publishAttempts[authMethodSubject][today]++
return nil
}

// GetPublishCount returns the number of publishes for an authenticated user on a specific date
func (db *MemoryDB) GetPublishCount(_ context.Context, authMethodSubject string, date time.Time) (int, error) {
db.mu.RLock()
defer db.mu.RUnlock()

dateStr := date.Format(time.DateOnly)
if db.publishAttempts[authMethodSubject] == nil {
return 0, nil
}
return db.publishAttempts[authMethodSubject][dateStr], nil
}

// CheckAndIncrementPublishCount atomically checks if the count is under the limit and increments if so
func (db *MemoryDB) CheckAndIncrementPublishCount(_ context.Context, authMethodSubject string, limit int) (currentCount int, incrementSuccessful bool, err error) {
db.mu.Lock()
defer db.mu.Unlock()

today := time.Now().Format(time.DateOnly)

// Initialize authMethodSubject map if needed
if db.publishAttempts[authMethodSubject] == nil {
db.publishAttempts[authMethodSubject] = make(map[string]int)
}

// Get current count
currentCount = db.publishAttempts[authMethodSubject][today]

// Check if under limit and increment if so
if currentCount < limit {
db.publishAttempts[authMethodSubject][today]++
currentCount++ // Return the new count after increment
incrementSuccessful = true
} else {
incrementSuccessful = false
}

return currentCount, incrementSuccessful, nil
}

// For an in-memory database, this is a no-op
func (db *MemoryDB) Close() error {
return nil
Expand Down
21 changes: 21 additions & 0 deletions internal/database/migrations/005_add_publish_rate_limiting.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Add rate limiting table to track publish attempts by authenticated user and date
CREATE TABLE publish_attempts (
auth_method_subject VARCHAR(255) NOT NULL,
attempt_date DATE NOT NULL DEFAULT CURRENT_DATE,
attempt_count INTEGER NOT NULL DEFAULT 0,
first_attempt_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_attempt_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (auth_method_subject, attempt_date)
);

-- Index for efficient lookups by auth_method_subject
CREATE INDEX idx_publish_attempts_auth_subject ON publish_attempts(auth_method_subject);

-- Index for cleanup queries by date
CREATE INDEX idx_publish_attempts_date ON publish_attempts(attempt_date);

-- Comment for documentation
COMMENT ON TABLE publish_attempts IS 'Tracks daily publish attempts per authenticated user for rate limiting';
COMMENT ON COLUMN publish_attempts.auth_method_subject IS 'The authenticated user identifier (e.g., GitHub username)';
COMMENT ON COLUMN publish_attempts.attempt_date IS 'The date of the attempts (resets daily)';
COMMENT ON COLUMN publish_attempts.attempt_count IS 'Number of successful publishes on this date';
Loading
Loading