Documentation
¶
Overview ¶
Package serverpool manages a pool of credential entries keyed by GitLab token and URL.
Each unique GitLab Personal Access Token and GitLab URL pair gets its own Entry: a GitLab client, the configuration resolved for it (detected token scopes, detected CE/EE edition, any read_api narrowing), the user it belongs to, and an opaque Entry.Owner that names it wherever a shared component has to say whose work it is doing. The MCP server an entry is served by is *not* its own: one server is built per configuration shape and answers for every credential that resolves to that shape, since what a server holds — the tool catalog, the resources, the prompts — is decided by the configuration and never by the credential. Isolation is therefore not "a server each" but "a client each": every request runs under the client its own entry carries, and anything that was ever keyed on the server is keyed on the entry instead.
The pool has a configurable maximum size (WithMaxSize) and evicts when the limit is reached: the least recently used entry that WithInUse does not report as busy, falling back to the least recently used of all when every entry is busy, because the pool is bounded before it is polite. That fallback is counted apart from the ordinary case (Metrics.BusyEvictions) and logged at WARN, since it is the only path that ends work a client is waiting on. Token plus URL hashes (SHA-256) are used as pool keys so that raw tokens are never stored in memory.
The package also extracts GitLab tokens and per-request GitLab URLs from HTTP headers and includes an authentication-failure rate limiter for the HTTP MCP endpoint.
Isolation Model ¶
HTTP requests are routed to per-identity entries:
HTTP request
|
v
ExtractToken and ExtractGitLabURL
|
v
ServerPool.GetOrCreate
|
v
per-token, per-URL entry -> the server built for its configuration shape
This design keeps token scopes, edition detection, read-only mode and safe mode resolved per credential, while the tools, resources and prompts they select are built once and shared by every credential that selects the same ones.
Usage ¶
Create a pool with New, retrieve or create servers with ServerPool.GetOrCreate, and extract tokens from HTTP requests with ExtractToken:
pool := serverpool.New(cfg, factory, serverpool.WithMaxSize(100))
defer pool.Close()
handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
token := serverpool.ExtractToken(r)
gitlabURL, err := serverpool.ExtractGitLabURL(r, cfg.GitLabURL)
if err != nil {
return nil
}
srv, err := pool.GetOrCreate(token, gitlabURL)
if err != nil {
return nil
}
return srv
}, opts)
Index ¶
- Constants
- Variables
- func CredentialHash(token string) string
- func ExtractBearerToken(r *http.Request) string
- func ExtractGitLabURL(r *http.Request, defaultURL string) (string, error)
- func ExtractToken(r *http.Request) string
- func NormalizeGitLabURLs(raw []string) ([]string, error)
- type AuthRateLimiter
- type DisallowedGitLabURLError
- type DistinctTokenBudget
- type Entry
- type EvictionCause
- type InvalidGitLabURLError
- type Metrics
- type Option
- func WithBaseContext(fn func() context.Context) Option
- func WithIdleTimeout(d time.Duration) Option
- func WithInUse(fn func(*Entry) bool) Option
- func WithMaxCredentialAge(d time.Duration) Option
- func WithMaxSize(n int) Option
- func WithOnEvict(fn func(*Entry, EvictionCause)) Option
- func WithOnInsert(fn func(*Entry) bool) Option
- func WithRevalidateInterval(d time.Duration) Option
- type PermissionMissingError
- type RequestOptions
- type ServerFactory
- type ServerPool
- func (p *ServerPool) Admitted(token, gitlabURL string) bool
- func (p *ServerPool) Close()
- func (p *ServerPool) EvictServer(srv *mcp.Server) bool
- func (p *ServerPool) GetOrCreate(token, gitlabURL string) (*mcp.Server, error)
- func (p *ServerPool) GetOrCreateEntry(token, gitlabURL string, scopes []string) (*Entry, error)
- func (p *ServerPool) GetOrCreateEntryWithFacts(token, gitlabURL string, known *gitlabclient.TokenFacts) (*Entry, error)
- func (p *ServerPool) GetOrCreateWithScopes(token, gitlabURL string, scopes []string) (*mcp.Server, error)
- func (p *ServerPool) IdentityFor(token, gitlabURL string) (UserIdentity, bool)
- func (p *ServerPool) RememberBelowMinimum(record func(gitlabURL, token string))
- func (p *ServerPool) Size() int
- func (p *ServerPool) StartIdleEviction(ctx context.Context)
- func (p *ServerPool) StartRevalidation(ctx context.Context)
- func (p *ServerPool) Stats() Snapshot
- type Snapshot
- type UnnamedInstanceError
- type UserIdentity
Constants ¶
const DefaultIdleTimeout = tenancy.PoolIdleTimeout // register row POL-004
DefaultIdleTimeout is how long an entry may go unused before the pool reclaims it. Without it an abandoned entry survives until enough distinct token+URL pairs push it out of the LRU, holding a fully registered server and drawing a revalidation ping against GitLab every interval, forever.
const DefaultMaxCredentialAge = tenancy.CredentialMaxAge // register row ADM-008
DefaultMaxCredentialAge is the longest an entry keeps serving on the strength of a credential check made that long ago.
It exists because nothing else bounds the window between an operator revoking a token and this server ceasing to answer for it. An entry is verified once, when it is built; the fast path then returns it and refreshes lastUsed with no re-verification, so an entry in continuous use never idles out. Periodic revalidation normally keeps the window at the revalidation interval, but --revalidate-interval 0 is a documented, supported setting, and with it off an actively used entry survived for the life of the process. This is the floor under that: whatever the operator turns off, a credential is re-checked at least this often, because the entry is rebuilt from scratch and the rebuild runs [verifyCredential].
What survives inside the window is the *surface* — initialize, tools/list, the catalog, the resource and prompt listings — not the tenant's data, since every tool call forwards the token and GitLab answers 401 the moment it dies. An hour bounds that disclosure while costing at most one rebuild per hour per active credential.
const DefaultRevalidateInterval = tenancy.RevalidateInterval // register row ADM-009
DefaultRevalidateInterval is the default period between token re-validation checks via a lightweight GitLab API call.
const RequestOptionGitLabURL = "GITLAB-URL"
RequestOptionGitLabURL identifies the per-request GitLab URL header option.
const RequestOptionPrivateToken = "PRIVATE-TOKEN"
RequestOptionPrivateToken names the GitLab-standard credential header that legacy mode accepts alongside Authorization: Bearer. It is exported so the CORS layer advertises exactly the header this package reads, rather than a second copy of the string that could drift from it.
Variables ¶
var ErrCredentialBelowMinimum = errors.New("gitlab accepted the credential, which carries neither the read_api nor the api scope")
ErrCredentialBelowMinimum reports that GitLab accepted the credential and that it carries neither read_api nor api, the minimum every door admits at (ADR-0018, issue 952): the credential probe was answered 403 insufficient_scope, or the token's own description named only scopes below it, read_user alone among them.
It is a verdict about a genuine token, like ErrCredentialLacksProbePermission, so the caller must not charge an authentication failure for it (INV-007), and a refusal rather than an admission, since such a token can read nothing of the API the catalog serves. Callers map it to 403 and remember it: a token's scopes cannot change after it is created.
var ErrCredentialLacksProbePermission = errors.New("gitlab accepted the credential and refused it the permission the credential probe needs")
ErrCredentialLacksProbePermission reports that GitLab accepted the credential and refused the credential probe a permission its fine-grained grant lacks: User: Read, on GET /api/v4/user.
It is a verdict, like ErrInvalidCredential, and the opposite one. The token is genuine, since GitLab judges a grant only after it has authenticated the token, so the caller must not be charged an authentication failure for it or told to reauthorize a token that works (INV-007); and it is not served, since the probe is what the pool admits on and this one was not answered. Callers map it to 403 and quote GitLab's sentence, which PermissionMissingError carries.
var ErrCredentialProbeBusy = errors.New("credential verification is saturated, retry shortly")
ErrCredentialProbeBusy reports that no credential probe slot came free in time.
It is deliberately not ErrInvalidCredential: nothing was learned about the token, so the caller must map it to 503 and not to 401, and it must not be charged to any authentication budget. Telling a client with a perfectly good credential to reauthorize because the server was busy would be the same conflation of causes the front door already avoids for pool failures.
var ErrInvalidCredential = errors.New("gitlab rejected the credential")
ErrInvalidCredential reports that GitLab itself rejected the credential.
It is distinct from every other pool error: those mean the instance could not be reached or the server could not be built, whereas this one is a verdict from GitLab about the token. Callers map it to 401 rather than 503.
var ErrMissingGitLabURL = errors.New("this deployment publishes no GitLab instance, so the " + RequestOptionGitLabURL + " header must name the one this request is for")
ErrMissingGitLabURL reports a request that named no GitLab instance on a deployment that publishes none either.
It is the empty-allow-list counterpart of the refusal a multi-instance deployment makes: in both, choosing on the caller's behalf means transmitting their credential in full to a host they never named.
Functions ¶
func CredentialHash ¶ added in v3.2.0
CredentialHash names a credential in a log line without writing any of it: the first eight bytes of an HMAC-SHA-256 of the token, under a key drawn once per process, in hex.
It replaced the token's last four characters, which were credential material in clear: four characters of a working token on every pooled credential, or most of a short secret pasted where a token belongs. The handle keeps what the suffix was there for, tying a refusal, a pool entry and an ignored-options warning to the same credential within one process, and nothing else. It is keyed rather than a plain digest so that a log cannot confirm a guess: hashing a candidate password yields its handle only with the key, and the key never leaves the process. The price is that an operator cannot compute the handle from a client's token, and that one credential's handle differs across restarts and between replicas.
The name is load-bearing beyond this package: static analysis of clear-text logging treats a call whose name says it hashes as the end of a credential's flow, which is true of this function and would be false of a renamed suffix.
func ExtractBearerToken ¶
ExtractBearerToken returns only the Authorization: Bearer credential, ignoring PRIVATE-TOKEN. OAuth mode uses it so the gate authenticates as the identity the SDK middleware verified, never as a PRIVATE-TOKEN the same request might also carry.
func ExtractGitLabURL ¶
ExtractGitLabURL resolves the GitLab instance URL for an HTTP request. It is a compatibility wrapper around ResolveRequestOptions.
func ExtractToken ¶
ExtractToken retrieves the GitLab Personal Access Token from the HTTP request. It checks the following sources in order:
- PRIVATE-TOKEN header (GitLab standard)
- Authorization header with Bearer scheme
Returns the token string, or empty string if no token is found.
func NormalizeGitLabURLs ¶
NormalizeGitLabURLs canonicalizes a list of GitLab base URLs, dropping blanks and duplicates while preserving order. The first entry is the deployment's default instance, so order is meaningful and is not sorted.
Types ¶
type AuthRateLimiter ¶
type AuthRateLimiter struct {
// contains filtered or unexported fields
}
AuthRateLimiter tracks authentication failures per client IP and blocks clients that exceed the maximum failure count within the configured window.
The table is capped at [maxTrackedAuthSources] entries. At the cap a new key is not tracked rather than an existing record being evicted to make room: the records already there are the ones carrying evidence, and dropping one for a key the caller has just invented is precisely how a block would be cleared on demand. Saturating the table therefore costs the attacker their own accumulated count and buys them nothing, while the front door's second budget keeps bounding the source it actually came from: that one is keyed on the accepted socket, which no header can change. It bounds a source its own table tracks, and that table is capped the same way, so a source arriving once both are full is blocked by neither ([maxTrackedAuthSources]).
func NewAuthRateLimiter ¶
func NewAuthRateLimiter(maxFails int, window time.Duration) *AuthRateLimiter
NewAuthRateLimiter creates a rate limiter that blocks a client IP after maxFails authentication failures within the given time window.
func (*AuthRateLimiter) BlockedFor ¶ added in v3.1.0
func (l *AuthRateLimiter) BlockedFor(ip string) (bool, time.Duration)
BlockedFor reports whether the address is blocked and for how much longer.
The remaining time is what a caller answers Retry-After with, and it is not the configured window: a block runs from the first failure of the window, so an address that reaches the limit late in one is released sooner than a whole window from now. Announcing the window instead held a client back for up to twice as long as the block actually lasted, and made a comparison against another budget's remaining time meaningless.
func (*AuthRateLimiter) Cleanup ¶
func (l *AuthRateLimiter) Cleanup()
Cleanup removes expired entries. Call periodically to prevent memory growth.
func (*AuthRateLimiter) IsBlocked ¶
func (l *AuthRateLimiter) IsBlocked(ip string) bool
IsBlocked returns true if the IP has exceeded the failure limit within the window.
func (*AuthRateLimiter) Limit ¶ added in v3.4.0
func (l *AuthRateLimiter) Limit() int
Limit is the count of failures inside a window that blocks a key, as the limiter was built with it. The transport-source budget bounds the pairs it keeps for one source at it, since a source charged that many distinct keys is blocked, and remembering more of its keys teaches nothing.
func (*AuthRateLimiter) RecordFailure ¶
func (l *AuthRateLimiter) RecordFailure(ip string) bool
RecordFailure records an authentication failure for the given IP, and reports whether it did.
It does not for a key the full table has no room for, which is the one failure the cap leaves uncounted. A caller that keeps a record of its own about the key should keep it only when this reports true, so that what it keeps is bounded by this table rather than by the keys a caller fails with. The transport-source budget keeps its pairs that way (register row AUB-002, issue 982).
type DisallowedGitLabURLError ¶
type DisallowedGitLabURLError struct {
Allowed []string
}
DisallowedGitLabURLError reports a GITLAB-URL header naming an instance the deployment does not publish.
It names the allowed instances in its message, for a client that guessed wrong and needs to know what it may ask for. Whether that message reaches the caller is the gate's decision, not this type's: in oauth mode the same list is already served unauthenticated as RFC 9728 authorization_servers, while legacy mode publishes no metadata document and reaches this rejection before the credential is validated, so the gate redacts it there. The rejected value is deliberately not echoed — it is caller-controlled text.
func (*DisallowedGitLabURLError) Error ¶
func (e *DisallowedGitLabURLError) Error() string
Error implements the [error] interface. It lists the published instances and never the rejected value, which is caller-controlled text.
type DistinctTokenBudget ¶ added in v3.1.0
type DistinctTokenBudget struct {
// contains filtered or unexported fields
}
DistinctTokenBudget counts the distinct credentials one address has had refused inside a window, and blocks that address for longer each time the count is reached.
It is the slow half of the pair whose fast half is AuthRateLimiter, and the two answer different questions on purpose. Ten failures in a minute is a stuck client retrying one bad token as much as it is an attack, so the answer to it is a block that lasts a minute and forgives. Fifty *distinct* invalid tokens from one address inside ten minutes is only an attack: a person has one token, and a fleet behind a NAT has one each, so the distinct count is the one thing a legitimate neighbor never produces and a sprayer cannot avoid producing.
What it protects is not the refusal, which this server makes cheaply either way. Every distinct token GitLab refuses that reaches verification is a GET /api/v4/user from the deployment's own address, the token in PRIVATE-TOKEN in legacy mode and as a Bearer token in oauth mode, and GitLab counts it as unauthenticated traffic of that address. Against GitLab 19.5 with its unauthenticated API throttle on, the probe in test/e2e/gitlab/ce/auth_throttle_probe_test.go saw each invalid token sent in PRIVATE-TOKEN counted once against the per-IP limit, the server's own legacy verification of one included. GitLab.com counted an invalid token sent as a Bearer token once in the same way in October 2026, and GitLab 19.5 reads both headers through one finder whose refusal of a token leaves the request unauthenticated (Gitlab::Auth::RequestAuthenticator). Past the limit GitLab answered every unauthenticated request from the address 429, an anonymous one too, while a token GitLab accepts was still verified and served from it, counted by no unauthenticated limit (GitLab's authenticated API limit, which counts per user, was off on that instance, as it is by default); no ban followed, and the next window counted an invalid token afresh. GitLab's failed-authentication ban covers Git and the container registry and not the API, and the unauthenticated API throttle is off by default on a self-managed instance. In October 2026 GitLab.com answered with a limit of 500 unauthenticated requests a minute per address; GitLab announced 60 an hour for unauthenticated traffic from 19 October 2026, which its rate limits page still listed as proposed.
A sprayer therefore spends the deployment's unauthenticated allowance with GitLab, not the standing of the callers it serves. What lands on the server once the allowance is spent is that its verification of every token GitLab does not accept, every invented one among them, is answered 429, which is no verdict on the token: in legacy mode the pool admits a credential its probe got no verdict for, so an invented token is then given an entry ([verifyCredential]), and in oauth mode the gate answers it 503 and charges nothing. A token GitLab accepts is still verified and served. The distinct count bounds that spending where the failure count does not: a sprayer kept under AuthRateLimiter's limit is never blocked by it, and one that is not kept under it is blocked by it a window at a time, while this budget blocks it for longer each time it is reached (at the defaults a minute, then ten, then an hour).
Both tables are bounded, and both ceilings are reached by the same reasoning as AuthRateLimiter's: the keys come from whoever is calling. The address table shares that limiter's cap, and the digest set per address needs no cap of its own because reaching the limit both blocks the address and clears the set, so it cannot hold more than limit entries.
A nil *DistinctTokenBudget is usable and answers "not blocked" to everything, which is what a deployment that has turned the budget off gets.
func NewDistinctTokenBudget ¶ added in v3.1.0
func NewDistinctTokenBudget(limit int, window, step time.Duration) *DistinctTokenBudget
NewDistinctTokenBudget returns a budget that blocks an address once limit distinct credentials of its have been refused inside window, for step, then ten times step, then sixty times step, resetting after sixty times step of silence.
A limit of zero or less turns it off, and so does a window or step of zero or less: the constructor returns nil rather than a live object with limits nothing can reach, so "off" is one state rather than several. Which settings mean off is the tenant policy register's answer, tenancy.EscalationOn.
func (*DistinctTokenBudget) Blocked ¶ added in v3.1.0
func (b *DistinctTokenBudget) Blocked(address string) (bool, time.Duration)
Blocked reports whether the address is currently blocked, and for how much longer, which is what the caller answers Retry-After with.
It records the attempt as activity while a block is on, which is the only place that can: a blocked caller is refused before its credential is read, so DistinctTokenBudget.Charge never sees it. Without this an address that kept hammering through an hour-long block read as an hour of silence, and the ladder it had climbed was forgiven the moment the block lifted.
func (*DistinctTokenBudget) Charge ¶ added in v3.1.0
func (b *DistinctTokenBudget) Charge(address, token string) bool
Charge records that the given address has had this credential refused, and reports whether that charge raised a new block.
The return value exists for the caller's telemetry: a block is an event worth counting once, and counting it on every later refusal from the same address would report the block's length rather than its occurrence.
A charge that arrives while the address is already blocked is ignored and returns false, which is the same answer the caller's own check would have given it a moment earlier.
func (*DistinctTokenBudget) Cleanup ¶ added in v3.1.0
func (b *DistinctTokenBudget) Cleanup()
Cleanup removes every record that has gone quiet long enough to be forgotten. Call it on the same schedule as AuthRateLimiter.Cleanup.
func (*DistinctTokenBudget) Len ¶ added in v3.1.0
func (b *DistinctTokenBudget) Len() int
Len reports how many addresses are tracked, for tests and for the saturation warning's sake.
type Entry ¶
type Entry struct {
// contains filtered or unexported fields
}
Entry is one pooled credential: a GitLab client, the configuration resolved for it, the user it belongs to, and the MCP server that serves it.
The server is deliberately not the entry's own. Since one server is built per configuration shape and shared by every credential that hashes to it, the same *mcp.Server answers for many entries, and a caller that holds only that pointer can no longer say which credential a request belongs to. Everything that used to be keyed on the server — the tag its sessions carry, the subscription watchers, the rate-limit bucket, the caller identity — is keyed on the entry instead, and Entry.Owner is the opaque name it goes by.
func (*Entry) Client ¶
func (e *Entry) Client() *gitlabclient.Client
Client returns the GitLab client carrying this entry's credential.
func (*Entry) Config ¶
func (e *Entry) Config() *config.ServerConfig
Config returns the configuration resolved for this entry: the process settings, plus the instance, tier and token-scope narrowing discovered when it was built.
func (*Entry) Identity ¶
func (e *Entry) Identity() UserIdentity
Identity returns the GitLab user behind this entry's credential, whose zero value means the lookup did not succeed.
func (*Entry) Owner ¶
Owner returns the opaque token naming this entry.
It is minted here, from crypto/rand.Text, and is never derived from the credential, the user or the instance: it travels in the `_meta` of a resource-updated notification so a shared server can tell whose watcher produced it, and anything derived from the credential would be a credential on the wire. It is unique per entry and per process, so a rebuilt entry for the same token is a different owner, which is what makes eviction forget the sessions that belonged to the entry that is gone.
type EvictionCause ¶
type EvictionCause string
EvictionCause says which removal path dropped an entry.
It exists because "the entry is gone" is not enough for the caller to tell its client anything useful: a credential taken for size pressure is still valid and should reconnect at once, one GitLab has refused must re-authenticate first, and one dropped at shutdown should look for another instance. Without a cause, cmd/server could only say the same thing to all three, and it said the first.
The values are the strings the eviction metric already labels its series with, so the log line, the counter and the callback name one path one way. CausePoolClosed is the exception, having no metric: an eviction at shutdown is counted by nobody, since nothing observes a metric after the process ends.
const ( // CauseSizePressure is a full pool taking a new credential. The entry it // took may or may not have been busy, and that difference is deliberately // not a second cause: it is the pool's own state, not this credential's, // and it changes nothing about what the client should do next. CauseSizePressure EvictionCause = "size_pressure" // CauseIdle is the idle sweep reclaiming an entry nobody has used. CauseIdle EvictionCause = "idle" // CauseStaleCredential is an entry whose credential has not been checked // against GitLab inside the ceiling, so it is rebuilt rather than trusted. CauseStaleCredential EvictionCause = "stale_credential" // CauseRejectedCredential is GitLab refusing the entry's credential on a // call: a 401 that named the credential, or a 401 that named nothing and // that the credential probe then confirmed. A permission refusal answered // with 401 never produces it, which is what keeps the "re-authenticate" // ending it maps to true. CauseRejectedCredential EvictionCause = "rejected_credential" // CauseInvalidCredential is the periodic revalidation finding that GitLab // now refuses the credential. CauseInvalidCredential EvictionCause = "invalid_credential" // CauseBelowMinimum is GitLab saying of a credential the pool already // serves that it is genuine and below the admission minimum, carrying // neither read_api nor api: the probe answered 403 insufficient_scope on a // confirmation or a revalidation, or the token's own description, asked // again because its kind was unknown when the entry was built, named only // scopes below it (issue 952). It is apart from the two causes above // because GitLab refused nothing: the credential works and reaches too // little, so its holder needs another token rather than to re-authenticate // the same one. CauseBelowMinimum EvictionCause = "below_minimum" // CauseRebuild is a configuration shape whose catalog registration failed, // taking every credential pointing at it. CauseRebuild EvictionCause = "rebuild" // CausePoolClosed is the pool shutting down. CausePoolClosed EvictionCause = "pool_closed" )
The causes, one per call site of [ServerPool.dropEntry]. A new removal path picks one of these or adds its own; what it must not do is leave the zero value, which names no path and would reach the caller as an ending it cannot explain.
type InvalidGitLabURLError ¶
type InvalidGitLabURLError struct {
// URL is the offending URL value. It is retained for programmatic
// inspection by callers but is deliberately omitted from [Error] output.
URL string
Reason string
}
InvalidGitLabURLError is returned when the GITLAB-URL header contains an invalid URL. The raw URL value is intentionally not included in the error message to avoid leaking embedded credentials or sensitive query parameters into server logs.
func (*InvalidGitLabURLError) Error ¶
func (e *InvalidGitLabURLError) Error() string
Error implements the [error] interface. The returned message contains only the validation InvalidGitLabURLError.Reason, never the raw URL, to avoid leaking credentials in logs.
type Metrics ¶
type Metrics struct {
Hits atomic.Int64
Misses atomic.Int64
// Evictions is the legacy total, and it overlaps SizeEvictions,
// BusyEvictions, InvalidEvictions and RebuildEvictions rather than
// complementing them: it counts all four together and always has. Keep it
// for the callers and assertions that already read it, and never export it
// as a series beside the four, which would double every eviction it covers.
Evictions atomic.Int64
// SizeEvictions and BusyEvictions split size pressure by what it took.
// SizeEvictions counts the ordinary case, where the scan found an entry
// doing no work of its own; BusyEvictions counts the fallback, where every
// pooled entry was busy and the least recently used of them went anyway.
// The second is the one an operator wants to see, because it is the only
// path that ends a subscription somebody is waiting on.
SizeEvictions atomic.Int64
BusyEvictions atomic.Int64
// InvalidEvictions counts entries dropped by [ServerPool.evictByKey], which
// is the periodic revalidation finding that GitLab now refuses the
// credential.
InvalidEvictions atomic.Int64
// RebuildEvictions counts entries dropped because a configuration shape's
// catalog registration failed, taking every credential pointing at it:
// [ServerPool.EvictServer] for the entries already pooled when the failure
// landed, and [ServerPool.dropRefusedEntry] for one inserted after it,
// which the insert callback refuses.
RebuildEvictions atomic.Int64
IdleEvictions atomic.Int64
RevalidationsFailed atomic.Int64
RevalidationsSucceeded atomic.Int64
// RevalidationsTransient counts revalidation rounds that could not reach
// a verdict — the instance was unreachable, or answered 5xx — and left
// the entry in place. Separated from RevalidationsFailed so an operator
// can tell "tokens are being revoked" from "GitLab was down for a
// minute", which used to look identical and evict the same way.
RevalidationsTransient atomic.Int64
// StaleCredentialEvictions counts entries dropped because their
// credential had not been checked within [DefaultMaxCredentialAge].
StaleCredentialEvictions atomic.Int64
// RejectedCredentialEvictions counts entries dropped because GitLab
// refused their credential on a call: a 401 that named the credential, or
// a 401 that named nothing and that the credential probe then confirmed.
// The token was revoked, expired or deleted while the entry was live, and
// the first refused data call is the signal rather than the next periodic
// check. A token carrying neither api nor read_api, which the GraphQL
// endpoint answers with such a 401, is refused at admission whenever its
// scopes can be read (issue 952), so it reaches this counter only when
// they could not be, before a revalidation learns them. A permission refusal answered with
// 401 is not counted here, since the probe finds the credential accepted;
// see UnauthorizedKept.
RejectedCredentialEvictions atomic.Int64
// BelowMinimumEvictions counts entries dropped because GitLab said their
// credential is below the admission minimum once the entry was serving
// ([CauseBelowMinimum]). A credential found below it at admission builds
// no entry and is counted by nothing here.
BelowMinimumEvictions atomic.Int64
// credential probe found GitLab still accepting the credential, so the
// entry was kept. Each is a permission refusal GitLab answered with 401
// rather than 403 (entry 55 of docs/development/upstream-bugs.md), and
// before the probe each one ended a valid credential's entry.
//
// It is read in the snapshot and exported nowhere else, like
// RevalidationsTransient: it is not an eviction, so it belongs to none of
// the series the eviction reason attribute splits.
UnauthorizedKept atomic.Int64
}
Metrics holds operational counters for the ServerPool. All counters are monotonically increasing and use lock-free atomic increments.
type Option ¶
type Option func(*ServerPool)
Option configures pool behavior.
func WithBaseContext ¶
WithBaseContext ties entry construction to a lifetime the caller controls, normally the server's root context.
Without it the GitLab lookups that build an entry run under context.Background() and survive shutdown until their own timeout expires. They are deliberately not derived from the request that triggered them — see [ServerPool.baseContext] — but "not this request" is not the same as "no lifetime at all".
The signature mirrors net/http.Server.BaseContext: a function, so the pool never holds a context of its own. A nil function is ignored, and one that returns nil falls back to context.Background.
func WithIdleTimeout ¶
WithIdleTimeout sets how long an entry may go unused before the pool reclaims it. Values <= 0 disable idle eviction, leaving the LRU bound as the only reclamation path.
func WithInUse ¶
WithInUse registers a callback that reports whether an entry is still doing work of its own, which exempts it from idle eviction.
The pool measures idleness by when an entry was last handed out, and that is the whole truth only while every piece of work a credential has running also passes through the pool. It does not: an open subscriptions/listen is a watcher polling GitLab directly, so a client that subscribed and then went quiet refreshes nothing here, and after --pool-idle-timeout it was evicted with its subscriptions ended under it while it was being served correctly.
Idle eviction skips such an entry outright. Size pressure prefers an entry that is not busy and takes a busy one only when every entry is ([ServerPool.evictLRU]), because otherwise the protection was defeasible by any caller willing to present --max-http-clients credentials of its own: the busy entries are the ones sitting at the LRU tail, precisely because their work does not pass through the pool. A credential GitLab has refused is evicted whatever this says, since there is nothing left to protect, and WithOnEvict is what tells the client in every case.
Like the other callbacks it runs under the pool's write lock: it must be a cheap read, must not block, and must not re-enter the pool. Size pressure calls it once per entry it passes over, so "cheap" is meant literally.
func WithMaxCredentialAge ¶
WithMaxCredentialAge sets the ceiling on how long an entry serves without its credential having been re-checked against GitLab.
Unlike the other options here it cannot be turned off, which is the point of it: see DefaultMaxCredentialAge. A value of zero or less keeps the default, and a value above [maxCredentialAgeCeiling] is clamped down to it.
func WithMaxSize ¶
WithMaxSize sets the maximum number of unique token entries in the pool. Values ≤ 0 are ignored; the default is 100.
func WithOnEvict ¶
func WithOnEvict(fn func(*Entry, EvictionCause)) Option
WithOnEvict registers a callback invoked with each entry the pool removes, and the cause that removed it.
It takes the entry rather than its server because a server is shared by every entry of one configuration shape: told only "this server is gone" a caller would drop state belonging to credentials that are still pooled.
The cause is what the caller turns into a reason for the client, so a removal path added later has to pick one of the EvictionCause values rather than leave the zero value: an unnamed cause reaches a subscriber as an ending nobody can explain, and the wrong named one tells it to do the wrong thing.
The callback runs under the pool's write lock: it must not block and must not re-enter the pool.
func WithOnInsert ¶
WithOnInsert registers a callback invoked with each server the pool has just cached, once it is reachable by key, and reporting whether the entry may stay.
It exists for work that must not start before the entry can be found again. A server whose catalog is registered in the background is the case: if that registration fails, the failure has to remove the entry, and a factory that started it would be racing its own insertion. The callback runs under the pool's write lock, so like WithOnEvict it must not block and must not re-enter the pool; starting a goroutine is what it is for.
Returning false is how a callback that has just learned the entry is unusable gets it out of the pool without re-entering it: the insertion is undone under the lock it is already holding, before ServerPool.GetOrCreateEntry returns, so the request that built the entry cannot answer its client while a poisoned entry is still cached for the client's retry to find. Evicting from a goroutine instead left exactly that window open. The entry is still handed back to the caller that built it, since it is the only thing to answer that one request with; it is simply not pooled, so the next request rebuilds.
func WithRevalidateInterval ¶
WithRevalidateInterval sets the interval between periodic token re-validation checks. Values ≤ 0 disable revalidation.
type PermissionMissingError ¶ added in v3.2.0
type PermissionMissingError struct {
// Description is GitLab's error_description, unfiltered: the instance's
// own text, which a caller bounds and filters before quoting it.
Description string
}
PermissionMissingError is ErrCredentialLacksProbePermission with the sentence GitLab refused the probe with, as the instance sent it.
func (*PermissionMissingError) Error ¶ added in v3.2.0
func (e *PermissionMissingError) Error() string
Error names the verdict and never the sentence, so a log line built from the error carries no text the instance chose.
func (*PermissionMissingError) Unwrap ¶ added in v3.2.0
func (e *PermissionMissingError) Unwrap() error
Unwrap lets errors.Is find ErrCredentialLacksProbePermission.
type RequestOptions ¶
RequestOptions contains the effective per-request options after applying server-wide MCP configuration precedence.
func ResolveRequestOptions ¶
func ResolveRequestOptions(r *http.Request, defaultURL string) (RequestOptions, error)
ResolveRequestOptions applies server-wide MCP configuration precedence to the request-provided options. When defaultURL is set, it is authoritative and any GITLAB-URL header is ignored. When defaultURL is empty, a GITLAB-URL header selects the instance per request and is required, because there is nothing else left to name one. Effective URLs are normalized so equivalent values hash to the same server-pool session key.
func ResolveRequestOptionsFor ¶
func ResolveRequestOptionsFor(r *http.Request, allowed []string) (RequestOptions, error)
ResolveRequestOptionsFor is ResolveRequestOptions for a deployment that published more than one instance.
The three cases are distinct on purpose:
- No allowed instances: the header selects freely and must be present. This is --allow-any-gitlab-url, where the operator has said any host is acceptable; they have not said gitlab.com is, and a request that names no instance carries a credential nobody has aimed anywhere. Answering it with the public GitLab sent a self-managed instance's token to a third party whenever a proxy stripped the header or a client could not set one.
- Exactly one: it is authoritative and the header is ignored, which is what a deployment pinning --gitlab-url has always done.
- More than one: the header selects among them, and a value that is not on the list is refused rather than ignored. Silently serving the first instance would answer a question the client did not ask, with someone else's data.
An allow-list is what makes a per-request instance safe in oauth mode. The server validates the bearer token against the instance it is about to use, so a free-form header would let a caller name a host of their own and be handed the token — the list keeps the choice with the operator while still letting one deployment serve gitlab.com and a self-managed instance.
func (RequestOptions) HasIgnoredOptions ¶
func (o RequestOptions) HasIgnoredOptions() bool
HasIgnoredOptions reports whether any request-provided options were ignored because server-wide MCP configuration is authoritative.
func (RequestOptions) IgnoredOptionsCopy ¶
func (o RequestOptions) IgnoredOptionsCopy() []string
IgnoredOptionsCopy returns a defensive copy of the ignored option names.
type ServerFactory ¶
type ServerFactory func(client *gitlabclient.Client, cfg *config.ServerConfig) (*mcp.Server, error)
ServerFactory creates a fully configured *mcp.Server with all tools, resources, and prompts registered for the given GitLab client and per-entry configuration. This is provided by the caller to decouple pool management from registration logic.
type ServerPool ¶
type ServerPool struct {
// contains filtered or unexported fields
}
ServerPool maintains a bounded set of *mcp.Server instances keyed by token plus GitLab URL hash (SHA-256). When the pool reaches maxSize, the least recently used entry is evicted. Entries are periodically re-validated against the GitLab API; entries with revoked tokens are evicted automatically.
func New ¶
func New(cfg *config.Config, factory ServerFactory, opts ...Option) *ServerPool
New creates a ServerPool. The cfg provides shared server-wide settings (GitLabURL, SkipTLSVerify, etc.). The factory function creates a fully registered *mcp.Server for each new GitLab client.
func (*ServerPool) Admitted ¶ added in v3.1.0
func (p *ServerPool) Admitted(token, gitlabURL string) bool
Admitted reports whether the pool already holds a usable entry for this credential: one GitLab accepted when it was built, that has not since been refused on a call, and whose credential is still inside the revalidation ceiling.
It answers the question ServerPool.GetOrCreateEntry answers on its fast path, and answers only that: a false here means the next call would take the slow path and reach GitLab, never that the credential is bad. That is what makes it usable as an exemption from the per-address authentication budget — a credential this pool is already serving costs a hash and a map read to recognize, and serving it again spends nothing upstream, whoever else shares its address. Anything this returns false for still has to earn its entry the ordinary way, which is what keeps a blocked address unable to spend the deployment's standing with GitLab.
The three conditions mirror that fast path deliberately. An entry marked rejected or past WithMaxCredentialAge is dropped and rebuilt on the next request, and a rebuild is a round trip, so neither may be reported as admitted.
func (*ServerPool) Close ¶
func (p *ServerPool) Close()
Close removes all entries from the pool. Active MCP sessions for evicted servers are not forcefully terminated — they will expire naturally via [StreamableHTTPOptions.SessionTimeout].
func (*ServerPool) EvictServer ¶
func (p *ServerPool) EvictServer(srv *mcp.Server) bool
EvictServer removes every entry served by srv, and reports whether it found any.
It exists for a build that fails after the entry is already cached. A server whose catalog registration ran in the background and failed is not usable and must not be handed to the next request for that credential: the pool would otherwise serve the poisoned entry until an idle timeout or a revalidation happened to replace it, which is an hour by default. Dropping it makes the next request rebuild, which is what a synchronous failure already does.
Every entry rather than the first: one server now answers for every credential of a configuration shape, and a registration that failed failed for all of them. Stopping at the first match would leave the others holding a server with no tools, which is the exact condition this exists to clear.
The scan is linear over the pool, which is bounded by --max-http-clients and only walked when a registration has failed, so it is not on any hot path.
func (*ServerPool) GetOrCreate ¶
func (p *ServerPool) GetOrCreate(token, gitlabURL string) (*mcp.Server, error)
GetOrCreate returns the *mcp.Server for the given token and GitLab URL, creating one if it doesn't exist. The pool key is derived from both the token and gitlabURL, so the same token against different GitLab instances gets separate server entries. It is safe for concurrent use. Returns an error if the GitLab client or MCP server cannot be created.
func (*ServerPool) GetOrCreateEntry ¶
func (p *ServerPool) GetOrCreateEntry(token, gitlabURL string, scopes []string) (*Entry, error)
GetOrCreateEntry is ServerPool.GetOrCreateWithScopes returning the whole pool entry rather than its server.
It is the form every caller that has to act per credential needs, and it became the primary one when servers started being shared between credentials of the same configuration shape: the server no longer identifies the caller, and the entry does.
func (*ServerPool) GetOrCreateEntryWithFacts ¶ added in v3.2.0
func (p *ServerPool) GetOrCreateEntryWithFacts(token, gitlabURL string, known *gitlabclient.TokenFacts) (*Entry, error)
GetOrCreateEntryWithFacts is ServerPool.GetOrCreateEntry for a caller that has already learned what the token is: its scopes and, for a personal access token, its id, as the OAuth verifier's introspection read them from the same instance for the same token (its cache keys on both). The id is what lets a fine-grained token's grant be read without asking the self endpoint again. A nil known means nothing was learned, and the pool asks the self endpoint itself (gitlabclient.DetectToken). So does a known whose kind is unknown (gitlabclient.TokenFacts.KindUnknown), keeping what it was handed when the self endpoint does not answer either.
func (*ServerPool) GetOrCreateWithScopes ¶
func (p *ServerPool) GetOrCreateWithScopes(token, gitlabURL string, scopes []string) (*mcp.Server, error)
GetOrCreateWithScopes is ServerPool.GetOrCreate for a caller that has already resolved the token's scopes.
OAuth mode has: verifying the bearer token required reading them. Passing them in spares a second introspection, and more importantly it is the only way the entry learns them at all — the PAT self endpoint the pool would otherwise ask does not answer for an OAuth access token, so a read_api OAuth token would look like "scopes unknown" and be served a catalog it cannot use. A nil slice means "not resolved"; the pool then detects them itself, exactly as before.
func (*ServerPool) IdentityFor ¶
func (p *ServerPool) IdentityFor(token, gitlabURL string) (UserIdentity, bool)
IdentityFor returns the GitLab user behind a pooled credential, and whether the pool holds an entry for it at all.
Reading rather than resolving is the point: the answer was determined when the entry was built, so a request costs a map lookup. A caller that gets ok=false has asked before ServerPool.GetOrCreate ran for this credential.
func (*ServerPool) RememberBelowMinimum ¶ added in v3.2.0
func (p *ServerPool) RememberBelowMinimum(record func(gitlabURL, token string))
RememberBelowMinimum installs the function the pool calls with a credential it serves once GitLab has said the credential is below the admission minimum, which ends its entry (CauseBelowMinimum). It is the door's record of that verdict (register row ADM-006), so the next request carrying the credential is refused from memory rather than rebuilt. A pool with none installed ends the entry all the same, and the next request rebuilds it, which reaches the verdict at admission.
It takes the record after New because the door is assembled after the pool it serves from; it is installed once, before the listener accepts a request, and is read atomically all the same.
A nil record installs none: storing a pointer to a nil function would make the next verdict call it.
func (*ServerPool) Size ¶
func (p *ServerPool) Size() int
Size returns the current number of entries in the pool.
func (*ServerPool) StartIdleEviction ¶
func (p *ServerPool) StartIdleEviction(ctx context.Context)
StartIdleEviction launches a background goroutine that reclaims entries unused for longer than the configured idle timeout. Cancel the context to stop it. It is a no-op when idle eviction is disabled.
Idle eviction runs independently of revalidation so that disabling one does not silently disable the other. It also needs no network I/O: an idle entry is dropped on its timestamp alone. That is the point, since the entries it reclaims are exactly the ones revalidation would otherwise keep pinging GitLab about on behalf of a client that is gone.
func (*ServerPool) StartRevalidation ¶
func (p *ServerPool) StartRevalidation(ctx context.Context)
StartRevalidation launches a background goroutine that periodically checks all pool entries for token validity using a lightweight GitLab API call. Entries that fail validation are evicted. Cancel the context to stop.
func (*ServerPool) Stats ¶
func (p *ServerPool) Stats() Snapshot
Stats returns a point-in-time Snapshot of pool metrics and state.
type Snapshot ¶
type Snapshot struct {
Hits int64 `json:"hits"`
Misses int64 `json:"misses"`
// Evictions is the legacy total described on [Metrics.Evictions]: it
// overlaps SizeEvictions, BusyEvictions, InvalidEvictions and
// RebuildEvictions, so a reader graphing the four must leave this one out.
Evictions int64 `json:"evictions"`
// SizeEvictions and BusyEvictions split size pressure by whether the entry
// it took was doing work of its own. See [Metrics.SizeEvictions].
SizeEvictions int64 `json:"size_evictions"`
BusyEvictions int64 `json:"busy_evictions"`
// InvalidEvictions counts entries dropped when revalidation found GitLab
// refusing the credential; RebuildEvictions counts entries dropped with a
// configuration shape whose registration failed.
InvalidEvictions int64 `json:"invalid_evictions"`
RebuildEvictions int64 `json:"rebuild_evictions"`
IdleEvictions int64 `json:"idle_evictions"`
RevalidationsFailed int64 `json:"revalidations_failed"`
RevalidationsSucceeded int64 `json:"revalidations_succeeded"`
RevalidationsTransient int64 `json:"revalidations_transient"`
StaleCredentialEvictions int64 `json:"stale_credential_evictions"`
// RejectedCredentialEvictions counts entries dropped because GitLab
// refused their credential on a call, by a 401 naming it or by a 401 the
// credential probe then confirmed.
RejectedCredentialEvictions int64 `json:"rejected_credential_evictions"`
// BelowMinimumEvictions counts entries dropped because GitLab said their
// credential is below the admission minimum. See
// [Metrics.BelowMinimumEvictions].
BelowMinimumEvictions int64 `json:"below_minimum_evictions"`
// UnauthorizedKept counts 401s that named no cause after which the
// credential probe found the credential still accepted, and the entry was
// kept. See [Metrics.UnauthorizedKept].
CurrentSize int `json:"current_size"`
MaxSize int `json:"max_size"`
CreatedAt time.Time `json:"created_at"`
}
Snapshot is a point-in-time copy of pool Metrics plus current state. Safe for JSON serialization and cross-goroutine use.
type UnnamedInstanceError ¶
type UnnamedInstanceError struct {
Allowed []string
}
UnnamedInstanceError reports a request that named no GitLab instance on a deployment that publishes several.
There is no in-band way for a client to say which of the published instances it authenticated against, and in oauth mode the verifier POSTs the bearer to whichever instance is resolved, so a default here would deliver the credential to an instance the caller never chose. The message names no instance: whether the published set may be echoed depends on the auth mode, which the caller knows and this package does not. Allowed carries the set for a caller that may.
func (*UnnamedInstanceError) Error ¶
func (e *UnnamedInstanceError) Error() string
Error implements the [error] interface. The message names no instance, for the reason given on the type.
type UserIdentity ¶
UserIdentity is the GitLab user a pooled credential belongs to.
It is resolved once when the entry is built, alongside tier and scope discovery, and then answers for every request that reuses the entry. The zero value means the lookup did not succeed — an instance that refuses /user to this token, say — which callers must treat as "unknown", never as "anonymous".
func (UserIdentity) Resolved ¶
func (u UserIdentity) Resolved() bool
Resolved reports whether the identity was actually determined.