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-only 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 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 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)) Option
- func WithRevalidateInterval(d time.Duration) Option
- type RequestOptions
- type ServerFactory
- type ServerPool
- 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) GetOrCreateWithScopes(token, gitlabURL string, scopes []string) (*mcp.Server, error)
- func (p *ServerPool) IdentityFor(token, gitlabURL string) (UserIdentity, bool)
- 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 = 1 * time.Hour
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 = 1 * time.Hour
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 = 15 * time.Minute
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 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 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.
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) 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) RecordFailure ¶
func (l *AuthRateLimiter) RecordFailure(ip string)
RecordFailure records an authentication failure for the given IP.
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 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 answering 401 to a call made with the // entry's credential. CauseRejectedCredential EvictionCause = "rejected_credential" // CauseInvalidCredential is the periodic revalidation finding that GitLab // now refuses the credential. CauseInvalidCredential EvictionCause = "invalid_credential" // 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 by [ServerPool.EvictServer],
// which is a configuration shape whose catalog registration failed taking
// every credential pointing at it.
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
// answered a call made with their credential with 401: the token was
// revoked or expired while the entry was live, and the first refused
// data call is the signal rather than the next periodic check.
RejectedCredentialEvictions 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.
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.
func WithRevalidateInterval ¶
WithRevalidateInterval sets the interval between periodic token re-validation checks. Values ≤ 0 disable revalidation.
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) 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) 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) 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)
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 on a 401 from a
// call made with their credential.
RejectedCredentialEvictions int64 `json:"rejected_credential_evictions"`
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.