mcp connection
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-09-22 10:58:02 +05:30
parent 4e1f746b22
commit f2aa3b3ad8
53 changed files with 12515 additions and 37 deletions

View File

@@ -0,0 +1,209 @@
// Package ratelimit is a fixed-window request limiter shared across API
// instances.
//
// It exists because the limiter this service already had — httpserver's
// attemptLimiter — is an in-process map, and an in-process limiter behind N
// replicas enforces N times the configured limit. That is fine for the thing it
// guards (failed logins, where the real defence is the password hash's cost)
// and not fine for an endpoint that writes a database row for any caller who
// can reach it.
//
// The existing limiter is deliberately left alone. Replacing it is not this
// phase's job, it would change login behaviour, and the two have different
// shapes: attemptLimiter counts FAILURES and resets on success, which is the
// right model for a password and the wrong one for a request budget.
//
// WHAT THIS GUARANTEES
//
// - Correct under concurrency, including across instances: the count comes
// back from the same statement that increments it, so two callers cannot
// both read "9" and both proceed.
// - Bounded memory: the state is a table, swept by the cleanup job.
// - No credential ever becomes a key: callers pass an already-hashed subject,
// and Key refuses to build a bucket from anything that looks raw.
//
// # WHAT IT DOES NOT GUARANTEE
//
// Exactness at a window boundary. A fixed window admits up to 2× the limit
// across the seam — ten requests at 11:59:59 and ten more at 12:00:01. A
// sliding window would fix that and costs a row per request. For abuse
// prevention the burst is acceptable and the trade is deliberate.
package ratelimit
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/krow/krow-backend/go-api/internal/repo"
)
// Limiter counts requests per bucket per window.
type Limiter struct {
db repo.Querier
now func() time.Time
// failOpen decides what happens when the DATABASE fails, which is the one
// judgement call in this package.
//
// Default false — fail CLOSED. A limiter that cannot count is a limiter
// that is not limiting, and these endpoints are the ones worth protecting
// most when things are already going wrong. The alternative, failing open,
// turns a database blip into an unmetered window on an endpoint that
// writes rows for anonymous callers.
//
// Configurable because that is not the right answer everywhere: a
// deployment that would rather serve MCP degraded than refuse it can say
// so deliberately, in one place, rather than by a comment somebody has to
// remember.
failOpen bool
}
// New builds a limiter over the shared pool.
func New(db repo.Querier) *Limiter {
return &Limiter{db: db, now: time.Now}
}
// WithClock replaces the clock, so window rollover can be tested without
// waiting for one.
func (l *Limiter) WithClock(now func() time.Time) *Limiter {
l.now = now
return l
}
// WithFailOpen makes a database failure permit the request rather than refuse
// it. See the field comment: the default is to refuse.
func (l *Limiter) WithFailOpen(open bool) *Limiter {
l.failOpen = open
return l
}
// Rule is one limit: how many requests, over how long.
type Rule struct {
// Name is the scope, and it becomes the bucket's prefix. Keep it stable —
// renaming a scope resets everyone's counter.
Name string
// Limit is the number of requests permitted per window.
Limit int
// Window is the fixed window's length.
Window time.Duration
}
// Decision is the answer for one request.
type Decision struct {
// Allowed is whether the caller may proceed.
Allowed bool
// Remaining is how many requests are left in this window, never negative.
Remaining int
// RetryAfter is how long until the window rolls over. Rendered into the
// Retry-After header on a 429, so a well-behaved client waits exactly long
// enough rather than guessing.
RetryAfter time.Duration
// Limit and Window echo the rule, for the response headers.
Limit int
Window time.Duration
}
// Subject hashes a bucket subject.
//
// EVERY caller must pass identifying material through this. A bucket key built
// from a raw token would write that token to a table, to any log line naming
// the bucket, and to every slow-query report the row ever appears in. Hashing
// costs nothing here — the value is never read back, only compared.
//
// Truncated to 32 hex characters: 128 bits, far beyond collision risk for a
// counter, and it keeps the keys readable in a psql session while still being
// irreversible.
func Subject(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])[:32]
}
// Allow records one request against a rule and reports whether it may proceed.
//
// The whole decision is one statement. It is worth reading, because everything
// this package claims about concurrency rests on it:
//
// INSERT INTO rate_limits (bucket, window_start, count, expires_at)
// VALUES ($1, $2, 1, $3)
// ON CONFLICT (bucket, window_start)
// DO UPDATE SET count = rate_limits.count + 1
// RETURNING count
//
// The row is created or incremented, and the resulting count comes back, in one
// round trip under one implicit transaction. Two instances racing on the same
// bucket serialise on the primary key, and each sees a distinct count. There is
// no read-then-write window for them to slip through.
//
// A request is counted even when it is refused. That is deliberate: a caller
// hammering a limit should not be able to keep their own window open by
// spending it, and the alternative — not counting refusals — makes the limit
// cheaper to probe.
func (l *Limiter) Allow(ctx context.Context, rule Rule, subject string) (Decision, error) {
now := l.now()
windowStart := now.Truncate(rule.Window)
expiresAt := windowStart.Add(rule.Window)
bucket := rule.Name + ":" + subject
var count int
err := l.db.QueryRow(ctx,
`INSERT INTO rate_limits (bucket, window_start, count, expires_at)
VALUES ($1, $2, 1, $3)
ON CONFLICT (bucket, window_start)
DO UPDATE SET count = rate_limits.count + 1
RETURNING count`,
bucket, windowStart, expiresAt).Scan(&count)
if err != nil {
if l.failOpen {
return Decision{Allowed: true, Remaining: rule.Limit, Limit: rule.Limit, Window: rule.Window},
fmt.Errorf("ratelimit: %w", err)
}
return Decision{Allowed: false, RetryAfter: rule.Window, Limit: rule.Limit, Window: rule.Window},
fmt.Errorf("ratelimit: %w", err)
}
remaining := rule.Limit - count
if remaining < 0 {
remaining = 0
}
return Decision{
Allowed: count <= rule.Limit,
Remaining: remaining,
RetryAfter: expiresAt.Sub(now),
Limit: rule.Limit,
Window: rule.Window,
}, nil
}
// Sweep deletes expired counters, in bounded batches.
//
// Bounded because an unbounded DELETE on a busy table takes a lock for as long
// as it takes to finish, and "as long as it takes" is not a number anyone can
// predict at 3am. A batch of a few thousand rows completes in milliseconds and
// can simply be run again.
//
// Safe to run concurrently: two sweeps delete disjoint sets because the
// subquery re-reads under each statement's own snapshot, and a row deleted
// twice is not an error.
func (l *Limiter) Sweep(ctx context.Context, batch int) (int64, error) {
if batch <= 0 {
batch = 5000
}
tag, err := l.db.Exec(ctx,
`DELETE FROM rate_limits
WHERE ctid IN (
SELECT ctid FROM rate_limits WHERE expires_at < $1 LIMIT $2
)`,
l.now(), batch)
if err != nil {
return 0, fmt.Errorf("ratelimit: sweep: %w", err)
}
return tag.RowsAffected(), nil
}

View File

@@ -0,0 +1,452 @@
package ratelimit
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/krow/krow-backend/go-api/internal/testutil"
)
func newLimiter(t *testing.T) (*Limiter, *testutil.Harness, *time.Time) {
t.Helper()
h := testutil.New(t)
clock := time.Now().Truncate(time.Hour) // a clean window boundary
l := New(h.Pool).WithClock(func() time.Time { return clock })
return l, h, &clock
}
var testRule = Rule{Name: "test.rule", Limit: 3, Window: time.Minute}
func mustAllow(t *testing.T, l *Limiter, subject string) Decision {
t.Helper()
d, err := l.Allow(context.Background(), testRule, Subject(subject))
if err != nil {
t.Fatalf("Allow: %v", err)
}
return d
}
/* ── The basic contract ─────────────────────────────────────────────────── */
func TestUnderAtAndOverTheLimit(t *testing.T) {
l, _, _ := newLimiter(t)
// Under: each of the first three is allowed, and remaining counts down.
for i := 1; i <= testRule.Limit; i++ {
d := mustAllow(t, l, "alice")
if !d.Allowed {
t.Fatalf("request %d of %d was refused", i, testRule.Limit)
}
if want := testRule.Limit - i; d.Remaining != want {
t.Errorf("request %d: remaining = %d, want %d", i, d.Remaining, want)
}
}
// Over: the next one is refused and carries a usable Retry-After.
d := mustAllow(t, l, "alice")
if d.Allowed {
t.Fatal("the request past the limit was allowed")
}
if d.Remaining != 0 {
t.Errorf("remaining = %d, want 0", d.Remaining)
}
if d.RetryAfter <= 0 || d.RetryAfter > testRule.Window {
t.Errorf("RetryAfter = %v, want a positive interval no longer than the window", d.RetryAfter)
}
}
// A refused request is still counted. Otherwise a caller at their limit could
// keep probing for free, and the limit would be cheaper to test than to respect.
func TestRefusedRequestsStillCount(t *testing.T) {
l, h, _ := newLimiter(t)
for i := 0; i < testRule.Limit+5; i++ {
mustAllow(t, l, "bob")
}
var count int
if err := h.Pool.QueryRow(context.Background(),
`SELECT count FROM rate_limits WHERE bucket LIKE $1`, testRule.Name+":%").Scan(&count); err != nil {
t.Fatalf("read counter: %v", err)
}
if count != testRule.Limit+5 {
t.Errorf("count = %d, want %d — refusals must be counted too", count, testRule.Limit+5)
}
}
/* ── Windows ────────────────────────────────────────────────────────────── */
func TestTheWindowResets(t *testing.T) {
l, _, clock := newLimiter(t)
for i := 0; i < testRule.Limit; i++ {
mustAllow(t, l, "carol")
}
if mustAllow(t, l, "carol").Allowed {
t.Fatal("expected to be at the limit")
}
// Roll into the next window.
*clock = clock.Add(testRule.Window)
l.WithClock(func() time.Time { return *clock })
if d := mustAllow(t, l, "carol"); !d.Allowed {
t.Error("the limit did not reset at the window boundary")
} else if d.Remaining != testRule.Limit-1 {
t.Errorf("remaining = %d, want %d after a reset", d.Remaining, testRule.Limit-1)
}
}
// A new window is a new ROW, not a reset of an existing counter. That is what
// makes two instances rolling over simultaneously safe: neither clobbers the
// other's increments.
func TestANewWindowIsANewRow(t *testing.T) {
l, h, clock := newLimiter(t)
mustAllow(t, l, "dave")
*clock = clock.Add(testRule.Window)
l.WithClock(func() time.Time { return *clock })
mustAllow(t, l, "dave")
var rows int
if err := h.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM rate_limits WHERE bucket LIKE $1`, testRule.Name+":%").Scan(&rows); err != nil {
t.Fatalf("count rows: %v", err)
}
if rows != 2 {
t.Errorf("%d rows, want 2 — each window must be its own row", rows)
}
}
/* ── Buckets are independent ────────────────────────────────────────────── */
func TestSubjectsAreIndependent(t *testing.T) {
l, _, _ := newLimiter(t)
// Exhaust one subject entirely.
for i := 0; i < testRule.Limit+2; i++ {
mustAllow(t, l, "user-a")
}
// A different subject must be untouched.
if d := mustAllow(t, l, "user-b"); !d.Allowed {
t.Error("one subject's limit affected another's")
}
if d := mustAllow(t, l, "org-a|user-a"); !d.Allowed {
t.Error("a compound subject collided with a simple one")
}
}
func TestRulesAreIndependent(t *testing.T) {
l, _, _ := newLimiter(t)
other := Rule{Name: "other.rule", Limit: 3, Window: time.Minute}
for i := 0; i < 5; i++ {
mustAllow(t, l, "shared")
}
d, err := l.Allow(context.Background(), other, Subject("shared"))
if err != nil {
t.Fatalf("Allow: %v", err)
}
if !d.Allowed {
t.Error("exhausting one rule exhausted another for the same subject")
}
}
/* ── No credential becomes a key ────────────────────────────────────────── */
// The property that matters most here: a bucket must never contain the thing it
// identifies. A token in this table is a token in every EXPLAIN, every slow
// query log and every backup.
func TestSubjectsAreHashedNotStored(t *testing.T) {
l, h, _ := newLimiter(t)
const secret = "a-very-secret-bearer-token-value"
mustAllow(t, l, secret)
var found int
if err := h.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM rate_limits WHERE bucket LIKE '%' || $1 || '%'`,
secret).Scan(&found); err != nil {
t.Fatalf("scan: %v", err)
}
if found != 0 {
t.Error("the raw subject appears in the rate_limits table")
}
// And the hash is stable, or a caller would get a fresh budget per request.
if Subject(secret) != Subject(secret) {
t.Error("Subject is not deterministic")
}
if Subject(secret) == secret {
t.Error("Subject returned the raw value")
}
if len(Subject(secret)) != 32 {
t.Errorf("Subject length = %d, want 32", len(Subject(secret)))
}
}
/* ── Concurrency ────────────────────────────────────────────────────────── */
// The claim this package rests on: the count comes back from the statement that
// increments it, so concurrent callers cannot both read the same value and both
// proceed. Run with -race.
func TestConcurrentCallersDoNotLoseIncrements(t *testing.T) {
h := testutil.New(t)
clock := time.Now().Truncate(time.Hour)
l := New(h.Pool).WithClock(func() time.Time { return clock })
const callers = 40
rule := Rule{Name: "concurrent.rule", Limit: 10, Window: time.Minute}
var wg sync.WaitGroup
var mu sync.Mutex
allowed := 0
for i := 0; i < callers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
d, err := l.Allow(context.Background(), rule, Subject("hot-subject"))
if err != nil {
return
}
if d.Allowed {
mu.Lock()
allowed++
mu.Unlock()
}
}()
}
wg.Wait()
// EXACTLY the limit. Not "about" — if increments were lost, more would have
// been allowed; if the statement were not atomic, the count would be wrong
// in either direction.
if allowed != rule.Limit {
t.Errorf("%d of %d concurrent callers allowed, want exactly %d",
allowed, callers, rule.Limit)
}
var count int
if err := h.Pool.QueryRow(context.Background(),
`SELECT count FROM rate_limits WHERE bucket LIKE $1`, rule.Name+":%").Scan(&count); err != nil {
t.Fatalf("read counter: %v", err)
}
if count != callers {
t.Errorf("counter = %d, want %d — increments were lost", count, callers)
}
}
/* ── Failure behaviour ──────────────────────────────────────────────────── */
// A limiter that cannot count must refuse by default. Failing open turns a
// database blip into an unmetered window on the endpoints most worth guarding.
func TestFailsClosedByDefault(t *testing.T) {
l := New(brokenQuerier{}).WithClock(time.Now)
d, err := l.Allow(context.Background(), testRule, Subject("x"))
if err == nil {
t.Fatal("expected an error from a broken database")
}
if d.Allowed {
t.Error("the limiter failed OPEN by default; it must fail closed")
}
if d.RetryAfter <= 0 {
t.Error("a fail-closed decision carries no Retry-After")
}
}
func TestFailOpenIsOptIn(t *testing.T) {
l := New(brokenQuerier{}).WithFailOpen(true)
d, err := l.Allow(context.Background(), testRule, Subject("x"))
if err == nil {
t.Fatal("expected an error")
}
if !d.Allowed {
t.Error("WithFailOpen(true) did not permit the request")
}
}
/* ── Sweep ──────────────────────────────────────────────────────────────── */
func TestSweepRemovesOnlyExpiredWindows(t *testing.T) {
l, h, clock := newLimiter(t)
ctx := context.Background()
mustAllow(t, l, "old")
// Move past the old window, and open a new one.
*clock = clock.Add(2 * testRule.Window)
l.WithClock(func() time.Time { return *clock })
mustAllow(t, l, "current")
removed, err := l.Sweep(ctx, 100)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if removed != 1 {
t.Errorf("swept %d rows, want 1", removed)
}
// The live window must survive.
var remaining int
_ = h.Pool.QueryRow(ctx, `SELECT count(*) FROM rate_limits`).Scan(&remaining)
if remaining != 1 {
t.Errorf("%d rows left, want 1 — the live window was swept", remaining)
}
// Idempotent: a second sweep removes nothing and does not error.
if again, err := l.Sweep(ctx, 100); err != nil || again != 0 {
t.Errorf("second sweep: removed %d, err %v; want 0, nil", again, err)
}
}
func TestSweepIsBounded(t *testing.T) {
l, h, clock := newLimiter(t)
ctx := context.Background()
for i := 0; i < 10; i++ {
mustAllow(t, l, "subject-"+strings.Repeat("x", i))
}
*clock = clock.Add(2 * testRule.Window)
l.WithClock(func() time.Time { return *clock })
removed, err := l.Sweep(ctx, 4)
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if removed != 4 {
t.Errorf("swept %d, want exactly the batch size 4", removed)
}
var left int
_ = h.Pool.QueryRow(ctx, `SELECT count(*) FROM rate_limits`).Scan(&left)
if left != 6 {
t.Errorf("%d rows left, want 6", left)
}
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
// brokenQuerier fails every call, standing in for an unreachable database.
//
// Satisfies repo.Querier with pgx's real types, so this is the same interface
// the production limiter takes — a hand-rolled stand-in would prove the code
// works against a stand-in.
type brokenQuerier struct{}
func (brokenQuerier) Query(context.Context, string, ...any) (pgx.Rows, error) {
return nil, errBroken
}
func (brokenQuerier) QueryRow(context.Context, string, ...any) pgx.Row { return brokenRow{} }
func (brokenQuerier) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
return pgconn.CommandTag{}, errBroken
}
type brokenRow struct{}
func (brokenRow) Scan(...any) error { return errBroken }
var errBroken = errString("ratelimit test: database unavailable")
type errString string
func (e errString) Error() string { return string(e) }
/* ── The fixed-window boundary, measured ────────────────────────────────── */
// The known limitation, asserted rather than assumed.
//
// A fixed window admits up to 2× the limit across a boundary: the limit at the
// end of one window and the limit again at the start of the next. This test
// measures that burst exactly, so the number in the documentation is a fact
// rather than a claim, and so a future change to the algorithm has to
// deliberately update it.
func TestFixedWindowBoundaryBurstIsExactlyTwice(t *testing.T) {
h := testutil.New(t)
// Start just inside a window, so "end of window" is reachable.
clock := time.Now().Truncate(time.Minute).Add(59 * time.Second)
l := New(h.Pool).WithClock(func() time.Time { return clock })
rule := Rule{Name: "boundary.rule", Limit: 5, Window: time.Minute}
allowed := 0
// Spend the whole limit at the very end of window 1.
for i := 0; i < rule.Limit; i++ {
d, err := l.Allow(context.Background(), rule, Subject("boundary"))
if err != nil {
t.Fatalf("Allow: %v", err)
}
if d.Allowed {
allowed++
}
}
// One second later, window 2 begins.
clock = clock.Add(time.Second)
l.WithClock(func() time.Time { return clock })
for i := 0; i < rule.Limit; i++ {
d, err := l.Allow(context.Background(), rule, Subject("boundary"))
if err != nil {
t.Fatalf("Allow: %v", err)
}
if d.Allowed {
allowed++
}
}
// Exactly 2× the limit in just over a second. This is the documented
// worst case — not worse, and not better.
if allowed != rule.Limit*2 {
t.Errorf("%d requests allowed across the boundary, want exactly %d (2× the limit)",
allowed, rule.Limit*2)
}
// And the burst does NOT continue: window 2's budget is now spent.
d, err := l.Allow(context.Background(), rule, Subject("boundary"))
if err != nil {
t.Fatalf("Allow: %v", err)
}
if d.Allowed {
t.Error("the burst continued past 2× the limit")
}
}
// Retry-After must point at the END of the current window, not at a fixed
// duration. A client told to wait a whole window when the window is nearly over
// waits twice as long as it needs to; one told to wait too little retries into
// the same refusal.
func TestRetryAfterPointsAtTheWindowBoundary(t *testing.T) {
h := testutil.New(t)
rule := Rule{Name: "retry.rule", Limit: 1, Window: time.Minute}
for _, offset := range []time.Duration{0, 15 * time.Second, 45 * time.Second, 59 * time.Second} {
clock := time.Now().Truncate(time.Minute).Add(offset)
l := New(h.Pool).WithClock(func() time.Time { return clock })
subject := Subject("retry-" + offset.String())
// Spend the budget, then be refused.
_, _ = l.Allow(context.Background(), rule, subject)
d, err := l.Allow(context.Background(), rule, subject)
if err != nil {
t.Fatalf("Allow: %v", err)
}
if d.Allowed {
t.Fatalf("offset %v: expected a refusal", offset)
}
want := rule.Window - offset
if d.RetryAfter != want {
t.Errorf("offset %v: RetryAfter = %v, want %v (the remainder of the window)",
offset, d.RetryAfter, want)
}
}
}

View File

@@ -0,0 +1,163 @@
package ratelimit
import "time"
// The rule set for the MCP and OAuth surface, in one place.
//
// Every number below is a judgement, so each carries the reasoning that
// produced it. They are starting values: the right way to change one is to
// change it here, with the comment updated, rather than to pass a different
// number at a call site.
//
// TWO PRINCIPLES SHAPE ALL OF THEM
//
// 1. Limit the scarce thing, not the request. Registration writes a row for an
// anonymous caller, so it is limited hard. A tool call reads rows the
// caller may already read through the product, so it is limited loosely —
// the cost there is database load, not access.
//
// 2. Key by the narrowest identity available. An IP is a whole office behind
// NAT; a token is one connection. Limiting an authenticated endpoint by IP
// would make one person's loop everyone's outage.
var (
// Registration: 10 per hour per IP.
//
// The tightest limit here, because /oauth/register is the only endpoint
// that WRITES for a caller with no credential at all — RFC 7591 requires
// exactly that. A legitimate client registers once per installation and
// then never again, so ten is already generous by two orders of magnitude;
// it is set there only so a developer retrying a broken integration does
// not lock themselves out.
//
// Keyed by IP because there is nothing else to key by: the caller is
// anonymous by definition at this point.
OAuthRegister = Rule{Name: "oauth.register", Limit: 10, Window: time.Hour}
// Authorization: 20 per hour per IP+user.
//
// A person clicking Approve does it once. Twenty allows for a browser
// reload, a mistyped password, a client retrying a flow, and a developer
// testing — and stops a script walking the authorization endpoint to farm
// consent pages or probe client ids.
//
// IP AND user, not either alone: keying by user only would let one
// attacker burn an innocent person's budget by naming them, and keying by
// IP only would make an office share one person's allowance.
OAuthAuthorize = Rule{Name: "oauth.authorize", Limit: 20, Window: time.Hour}
// Token exchange: 30 per hour per client.
//
// One exchange per authorization, and an authorization is already limited
// above — so this is not the primary defence. It is here to bound
// brute-forcing a code or a verifier: an authorization code lives 60
// seconds and is single-use, and 30 attempts an hour makes guessing one
// hopeless rather than merely improbable.
OAuthToken = Rule{Name: "oauth.token", Limit: 30, Window: time.Hour}
// Refresh: 60 per hour per token family.
//
// An access token lives 15 minutes, so a well-behaved client refreshes
// about 4 times an hour. Sixty leaves room for a client that refreshes
// eagerly, or one running several sessions, while bounding a loop.
//
// Keyed by FAMILY rather than by token, because the token changes on every
// rotation — keying by token would give each rotation a fresh budget,
// which is the same as no budget at all.
OAuthRefresh = Rule{Name: "oauth.refresh", Limit: 60, Window: time.Hour}
// MCP tool calls: 60 a minute, and 1000 an hour, per token.
//
// BOTH, because they stop different things. The minute limit stops a tight
// loop — a model retrying a failing call, or a bug — from becoming a spike.
// The hour limit stops a slow, sustained drain that would sit under the
// minute limit forever: 59 calls a minute is 3,540 an hour, which is a lot
// of queries for one connection.
//
// Sixty a minute is well above interactive use. A person asking questions
// generates a handful of calls per turn, and a model doing several lookups
// for one answer still lands in single figures.
MCPToolCallPerMinute = Rule{Name: "mcp.call.min", Limit: 60, Window: time.Minute}
MCPToolCallPerHour = Rule{Name: "mcp.call.hour", Limit: 1000, Window: time.Hour}
// Per-organisation ceiling: 5000 an hour.
//
// The backstop for the case the per-token limits cannot see: one tenant
// with many connected clients, each individually well-behaved, together
// saturating the database. Set well above the sum of a few active users so
// it is never reached in ordinary use — it exists to bound a runaway, not
// to ration normal work.
MCPPerOrgPerHour = Rule{Name: "mcp.org.hour", Limit: 5000, Window: time.Hour}
)
// A note on what is NOT rate limited here, and why.
//
// CONCURRENT CONNECTIONS. The plan proposed 10 concurrent MCP connections per
// user. That is not implemented, and it is not an oversight: this transport is
// stateless — one POST per message, no session, nothing held open — so there is
// no such thing as a concurrent connection to count. The thing that limit was
// reaching for is request rate, and the two limits above are that, measured
// directly. Implementing a connection counter over a stateless endpoint would
// mean inventing connection state purely so it could be limited.
//
// DISCOVERY. The two .well-known documents are static, cacheable for five
// minutes, and contain public URLs. Limiting them would add a database write to
// the cheapest endpoints on the surface, to protect nothing.
//
// REVOCATION. Deliberately unlimited. Revocation is the thing a person reaches
// for when something has gone wrong, and an attacker gains nothing by calling
// it — the worst they can do is revoke tokens they already hold. Rate limiting
// the emergency brake is the wrong trade.
/*
FAILURE BEHAVIOUR, RULE BY RULE
===============================
The question this section answers: when the database cannot be reached, does a
request get through?
EVERY RULE HERE FAILS CLOSED. Limiter.failOpen defaults to false and nothing in
this service sets it to true. The reasoning is the same for all of them and is
worth stating once rather than per-rule:
- A limiter that cannot count is not limiting. If a database outage lifted
the limits, then the moment the system is least able to absorb load is
exactly the moment its protections switch off — and an attacker who can
cause or wait for a blip gets an unmetered window on the endpoints that
write rows for anonymous callers.
- The cost of failing closed is bounded and visible: MCP returns 429 and
Claude retries. The cost of failing open is unbounded and silent.
- These endpoints are not load-bearing for the product. If the database is
down, /oauth/token cannot mint a token and /mcp cannot read a row anyway;
the limiter refusing first changes the error message, not the outcome.
WHAT IS EXPLICITLY NOT FAIL-OPEN, AND WHY IT MATTERS MOST
oauth.register Writes a row for a caller with no credential. Failing open
here is an unauthenticated write endpoint with no ceiling.
oauth.token Bounds brute-forcing a code or a verifier. Failing open
turns a 60-second, single-use code into one an attacker may
guess at without limit for the duration of the outage.
oauth.refresh Failing open removes the bound on a loop against a
long-lived credential.
THE ONE PLACE FAIL-OPEN WOULD BE DEFENSIBLE
A deployment that would rather serve MCP degraded than refuse it can call
WithFailOpen(true) on the limiter used for the mcp.* rules only — those guard
database load rather than access, and every call behind them is already
authenticated and already authorized by the policy table. That is a deliberate
operational trade, it is one line, and it is deliberately not the default.
It must NOT be applied to the oauth.* rules. Those guard the credential issuance
path, where the thing being limited is an attacker's number of attempts.
OBSERVABILITY
A limiter failure is logged at ERROR by the middleware (httpserver/mcplimit.go)
with the rule name and the decision, never the subject — the subject is a hash
of a credential. A sustained run of those log lines means the limiter is not
limiting, and is worth an alert.
*/