Skip to content

Commit aa84790

Browse files
author
Nitish Agarwal
committed
feat: implement user-based daily publish rate limiting (#21)
- Rate limit by authenticated user (authMethodSubject) instead of namespace - Admin bypass via hasGlobalPermissions parameter from auth handler - Atomic database operations with separate publish_attempts table - Integrated rate limiting directly into registry service - Support for rate limit exemptions with wildcard patterns - Comprehensive test coverage including concurrent request handling Configuration: - MCP_REGISTRY_RATE_LIMIT_ENABLED: Enable/disable rate limiting (default: true) - MCP_REGISTRY_RATE_LIMIT_PER_DAY: Daily publish limit per user (default: 10) - MCP_REGISTRY_RATE_LIMIT_EXEMPTIONS: Comma-separated exempt users/patterns Database changes: - New table: publish_attempts tracking auth_method_subject instead of namespace - Atomic check-and-increment operation prevents race conditions Testing: - All existing tests updated for new method signatures - New tests for concurrent requests, exemptions, and user-specific limits
1 parent 63cf08e commit aa84790

File tree

21 files changed

+1184
-33
lines changed

21 files changed

+1184
-33
lines changed

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,15 @@ MCP_REGISTRY_OIDC_EXTRA_CLAIMS=[{"hd":"modelcontextprotocol.io"}]
3939
# Grant admin permissions to OIDC-authenticated users
4040
MCP_REGISTRY_OIDC_EDIT_PERMISSIONS=*
4141
MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS=*
42+
43+
# Rate Limiting Configuration
44+
# Enable/disable rate limiting for publish operations
45+
MCP_REGISTRY_RATE_LIMIT_ENABLED=true
46+
47+
# Maximum number of servers a user can publish per day
48+
MCP_REGISTRY_RATE_LIMIT_PER_DAY=10
49+
50+
# Comma-separated list of authenticated users (auth subjects) exempt from rate limiting
51+
# Supports wildcards: anthropic/* to exempt all users under anthropic domain
52+
# Examples: modelcontextprotocol, anthropic/*, specific-username
53+
MCP_REGISTRY_RATE_LIMIT_EXEMPTIONS=

docs/guides/administration/admin-operations.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,27 @@ export SERVER_ID="<server-uuid>"
4444
```
4545

4646
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.
47+
48+
## Rate Limiting Configuration
49+
50+
The registry enforces daily publish rate limits to prevent abuse:
51+
52+
### Environment Variables
53+
54+
- `MCP_REGISTRY_RATE_LIMIT_ENABLED`: Enable/disable rate limiting (default: true)
55+
- `MCP_REGISTRY_RATE_LIMIT_PER_DAY`: Maximum publishes per user per day (default: 10)
56+
- `MCP_REGISTRY_RATE_LIMIT_EXEMPTIONS`: Comma-separated list of exempt users or patterns
57+
58+
### Exemption Patterns
59+
60+
Exemptions support wildcard patterns:
61+
- Exact match: `anthropic` (exempts user "anthropic")
62+
- Wildcard: `anthropic/*` (exempts "anthropic", "anthropic.claude", etc.)
63+
- Multiple exemptions: `anthropic/*,modelcontextprotocol,github/*`
64+
65+
### Notes
66+
67+
- Rate limits are per authenticated user (not per namespace)
68+
- Users with global admin permissions automatically bypass rate limits
69+
- Limits reset on a rolling 24-hour window
70+
- The counter is stored in the `publish_attempts` database table

docs/guides/publishing/publish-server.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,9 @@ With authentication complete, publish your server:
414414
mcp-publisher publish
415415
```
416416

417+
> [!NOTE]
418+
> **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).
419+
417420
You'll see output like:
418421
```
419422
✓ Successfully published

docs/reference/faq.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ Yes, extensions under the `x-publisher` property are preserved when publishing t
9090

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

93+
### What are the rate limits for publishing?
94+
95+
The registry enforces daily rate limits to prevent abuse:
96+
97+
- **Default limit**: 10 publishes per authenticated user per day (rolling 24-hour window)
98+
- **Who is affected**: All users except those with global admin permissions
99+
- **What counts**: Each successful publish counts toward your daily limit
100+
- **Exemptions**: Specific users or organizations can be exempted from rate limiting
101+
- **Error message**: If you exceed the limit, you'll receive an error with your current count
102+
103+
If you need a higher limit for legitimate use cases, please open an issue at https://github.com/modelcontextprotocol/registry/issues
104+
93105
### Can I publish a private server?
94106

95107
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`).
@@ -118,9 +130,15 @@ The MVP delegates security scanning to:
118130
- Namespace authentication requirements
119131
- Character limits and regex validation on free-form fields
120132
- Manual takedown of spam or malicious servers
133+
- Daily publish rate limiting per authenticated user (10 publishes per day by default)
134+
135+
The rate limiting system:
136+
- Limits are per authenticated user (not per namespace)
137+
- Default limit is 10 publishes per 24-hour period
138+
- Administrators with global permissions bypass rate limits
139+
- Specific users or patterns can be exempted from rate limiting
121140

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

internal/api/handlers/v0/edit_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestEditServerEndpoint(t *testing.T) {
3636
},
3737
Version: "1.0.0",
3838
}
39-
published, err := registryService.Publish(testServer)
39+
published, err := registryService.Publish(testServer, "testuser", false)
4040
assert.NoError(t, err)
4141
assert.NotNil(t, published)
4242
assert.NotNil(t, published.Meta)
@@ -56,7 +56,7 @@ func TestEditServerEndpoint(t *testing.T) {
5656
},
5757
Version: "1.0.0",
5858
}
59-
otherPublished, err := registryService.Publish(otherServer)
59+
otherPublished, err := registryService.Publish(otherServer, "testuser", false)
6060
assert.NoError(t, err)
6161
assert.NotNil(t, otherPublished)
6262
assert.NotNil(t, otherPublished.Meta)
@@ -76,7 +76,7 @@ func TestEditServerEndpoint(t *testing.T) {
7676
},
7777
Version: "1.0.0",
7878
}
79-
deletedPublished, err := registryService.Publish(deletedServer)
79+
deletedPublished, err := registryService.Publish(deletedServer, "testuser", false)
8080
assert.NoError(t, err)
8181
assert.NotNil(t, deletedPublished)
8282
assert.NotNil(t, deletedPublished.Meta)

internal/api/handlers/v0/publish.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,17 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
5353
return nil, huma.Error403Forbidden(buildPermissionErrorMessage(input.Body.Name, claims.Permissions))
5454
}
5555

56+
// Check if user has global permissions (admin)
57+
hasGlobalPermissions := false
58+
for _, perm := range claims.Permissions {
59+
if perm.ResourcePattern == "*" {
60+
hasGlobalPermissions = true
61+
break
62+
}
63+
}
64+
5665
// Publish the server with extensions
57-
publishedServer, err := registry.Publish(input.Body)
66+
publishedServer, err := registry.Publish(input.Body, claims.AuthMethodSubject, hasGlobalPermissions)
5867
if err != nil {
5968
return nil, huma.Error400BadRequest("Failed to publish server", err)
6069
}

internal/api/handlers/v0/publish_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ func TestPublishEndpoint(t *testing.T) {
192192
ID: "example/test-server-existing",
193193
},
194194
}
195-
_, _ = registry.Publish(existingServer)
195+
_, _ = registry.Publish(existingServer, "testuser", false)
196196
},
197197
expectedStatus: http.StatusBadRequest,
198198
expectedError: "invalid version: cannot publish duplicate version",

internal/api/handlers/v0/servers_test.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ func TestServersListEndpoint(t *testing.T) {
5252
},
5353
Version: "2.0.0",
5454
}
55-
_, _ = registry.Publish(server1)
56-
_, _ = registry.Publish(server2)
55+
_, _ = registry.Publish(server1, "testuser", false)
56+
_, _ = registry.Publish(server2, "testuser", false)
5757
},
5858
expectedStatus: http.StatusOK,
5959
},
@@ -71,7 +71,7 @@ func TestServersListEndpoint(t *testing.T) {
7171
},
7272
Version: "1.5.0",
7373
}
74-
_, _ = registry.Publish(server)
74+
_, _ = registry.Publish(server, "testuser", false)
7575
},
7676
expectedStatus: http.StatusOK,
7777
},
@@ -141,8 +141,8 @@ func TestServersListEndpoint(t *testing.T) {
141141
},
142142
Version: "1.0.0",
143143
}
144-
_, _ = registry.Publish(server1)
145-
_, _ = registry.Publish(server2)
144+
_, _ = registry.Publish(server1, "testuser", false)
145+
_, _ = registry.Publish(server2, "testuser", false)
146146
},
147147
expectedStatus: http.StatusOK,
148148
},
@@ -160,7 +160,7 @@ func TestServersListEndpoint(t *testing.T) {
160160
},
161161
Version: "1.0.0",
162162
}
163-
_, _ = registry.Publish(server)
163+
_, _ = registry.Publish(server, "testuser", false)
164164
},
165165
expectedStatus: http.StatusOK,
166166
},
@@ -188,8 +188,8 @@ func TestServersListEndpoint(t *testing.T) {
188188
},
189189
Version: "2.0.0",
190190
}
191-
_, _ = registry.Publish(server1)
192-
_, _ = registry.Publish(server2) // This will be marked as latest
191+
_, _ = registry.Publish(server1, "testuser", false)
192+
_, _ = registry.Publish(server2, "testuser", false) // This will be marked as latest
193193
},
194194
expectedStatus: http.StatusOK,
195195
},
@@ -217,8 +217,8 @@ func TestServersListEndpoint(t *testing.T) {
217217
},
218218
Version: "1.0.0",
219219
}
220-
_, _ = registry.Publish(server1)
221-
_, _ = registry.Publish(server2)
220+
_, _ = registry.Publish(server1, "testuser", false)
221+
_, _ = registry.Publish(server2, "testuser", false)
222222
},
223223
expectedStatus: http.StatusOK,
224224
},
@@ -274,10 +274,10 @@ func TestServersListEndpoint(t *testing.T) {
274274
},
275275
Version: "3.0.0",
276276
}
277-
_, _ = registry.Publish(server1v1)
278-
_, _ = registry.Publish(server1v2)
279-
_, _ = registry.Publish(server2)
280-
_, _ = registry.Publish(server3)
277+
_, _ = registry.Publish(server1v1, "testuser", false)
278+
_, _ = registry.Publish(server1v2, "testuser", false)
279+
_, _ = registry.Publish(server2, "testuser", false)
280+
_, _ = registry.Publish(server3, "testuser", false)
281281
},
282282
expectedStatus: http.StatusOK,
283283
},
@@ -384,7 +384,7 @@ func TestServersDetailEndpoint(t *testing.T) {
384384
Name: "com.example/test-server",
385385
Description: "A test server",
386386
Version: "1.0.0",
387-
})
387+
}, "testuser", false)
388388
assert.NoError(t, err)
389389

390390
testCases := []struct {
@@ -472,7 +472,7 @@ func TestServersEndpointsIntegration(t *testing.T) {
472472
Version: "1.0.0",
473473
}
474474

475-
published, err := registryService.Publish(testServer)
475+
published, err := registryService.Publish(testServer, "testuser", false)
476476
assert.NoError(t, err)
477477
assert.NotNil(t, published)
478478

internal/api/handlers/v0/telemetry_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestPrometheusHandler(t *testing.T) {
3131
ID: "example/test-server",
3232
},
3333
Version: "2.0.0",
34-
})
34+
}, "testuser", false)
3535
assert.NoError(t, err)
3636

3737
cfg := config.NewConfig()

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ type Config struct {
3333
OIDCExtraClaims string `env:"OIDC_EXTRA_CLAIMS" envDefault:""`
3434
OIDCEditPerms string `env:"OIDC_EDIT_PERMISSIONS" envDefault:""`
3535
OIDCPublishPerms string `env:"OIDC_PUBLISH_PERMISSIONS" envDefault:""`
36+
37+
// Rate Limiting Configuration
38+
RateLimitEnabled bool `env:"RATE_LIMIT_ENABLED" envDefault:"true"`
39+
RateLimitPerDay int `env:"RATE_LIMIT_PER_DAY" envDefault:"10"`
40+
RateLimitExemptions string `env:"RATE_LIMIT_EXEMPTIONS" envDefault:""` // comma-separated
3641
}
3742

3843
// NewConfig creates a new configuration with default values

0 commit comments

Comments
 (0)