Documentation
¶
Overview ¶
Package gitlab provides a wrapper around the GitLab REST API v4 client. Some domains additionally use the GitLab GraphQL API for endpoints not covered by client-go service wrappers (see ADR-0006).
The package also detects Personal Access Token scopes so HTTP server entries can register tools according to the authenticated token's capabilities.
Resilience ¶
Client initialization is lazy and recoverable. When GitLab is temporarily unavailable at startup, the server can enter degraded mode and retry initialization on later API calls with rate-limited health checks. This keeps local MCP startup responsive while still surfacing actionable errors to tool handlers.
Edition and Scope Detection ¶
The wrapper tracks GitLab.com and Premium/Ultimate detection separately from token-scope detection. HTTP mode uses that information to register the correct tool catalog for each token and GitLab URL pair.
Index ¶
- Constants
- Variables
- func CheckCallerNamedInstance(rawURL string) error
- func DetectScopes(ctx context.Context, client *gl.Client) []string
- func HTTPTransport(skipTLSVerify bool) http.RoundTripper
- func IsCredentialRejection(err error) bool
- func IsGitLabDotComURL(rawURL string) bool
- func NarrowToTokenScope(cfg *config.ServerConfig) bool
- func ScopeSatisfied(tokenScopes, requiredScopes []string) bool
- func WithClient(ctx context.Context, c *Client) context.Context
- func WriteCapable(scopes []string) bool
- type Client
- func ClientFrom(ctx context.Context) (*Client, bool)
- func NewClient(cfg *config.Config) (*Client, error)
- func NewClientWithToken(baseURL, token string, skipTLSVerify bool) (*Client, error)
- func NewClientWithTokenRetries(baseURL, token string, skipTLSVerify, disableRetries bool) (*Client, error)
- func NewOAuthClientWithToken(baseURL, token string, skipTLSVerify bool) (*Client, error)
- func NewUnboundClient(baseURL string) *Client
- func (c *Client) CredentialRejected(ctx context.Context) bool
- func (c *Client) CurrentUser(ctx context.Context) (*CurrentUserInfo, error)
- func (c *Client) CurrentUsername(ctx context.Context) (string, error)deprecated
- func (c *Client) DetectEnterprise(ctx context.Context, fallback bool) bool
- func (c *Client) DetectTier(ctx context.Context) edition.Tier
- func (c *Client) EnableLazyInit()
- func (c *Client) EnsureInitialized(ctx context.Context)
- func (c *Client) For(ctx context.Context) *Client
- func (c *Client) GL() *gl.Client
- func (c *Client) Initialize(ctx context.Context) (string, error)
- func (c *Client) IsEnterprise() bool
- func (c *Client) IsGitLabDotCom() bool
- func (c *Client) IsInitialized() bool
- func (c *Client) IsUnbound() bool
- func (c *Client) MarkInitialized()
- func (c *Client) MarkInstanceCallerNamed()
- func (c *Client) Ping(ctx context.Context) (string, error)
- func (c *Client) SetEnterprise(v bool)
- func (c *Client) SetMaxResponseBytes(n int64)
- func (c *Client) SetOnUnauthorized(fn func())
- func (c *Client) SetTier(t edition.Tier)
- func (c *Client) Tier() edition.Tier
- type CurrentUserInfo
- type ResponseCapture
Constants ¶
const AllowPrivateInstancesEnv = config.EnvPrefix + "ALLOW_PRIVATE_INSTANCES"
AllowPrivateInstancesEnv names the setting that permits a destination a caller or a redirect chose to resolve to a private, loopback, CGNAT, link-local, unique-local or unspecified address.
It is the opt-out for tier B and only tier B: the cloud metadata addresses are refused whatever it says, because no value of this setting is a claim that a GitLab API or a presigned object store is served from one.
The `--allow-private-instances` flag writes this variable, so there stays one reader; see cmd/server/env_flags.go for why that indirection exists.
const DefaultMaxResponseBytes int64 = 64 << 20
DefaultMaxResponseBytes is how much of one GitLab response body a client will read before giving up on it.
The number bounds the *decompressed* stream, which is the only place it can do any good: net/http gunzips a Content-Encoding: gzip response inside the base transport, so a cap applied to wire bytes bounds the compressed size and misses the amplification entirely. A 528 KB gzip that inflates to 67 MB is an ordinary artifact of a hostile or compromised upstream, and the SDK decodes whatever it is handed with no ceiling of its own.
64 MiB is chosen to sit above every response this server asks for in practice — the largest are job traces, which the jobs package truncates to 100 KB of its own accord, and artifact and export downloads, which are base64-encoded into a JSON-RPC response and are already unusable long before this — while being far below the multi-gigabyte working set an unbounded decode reaches. Raise it with Client.SetMaxResponseBytes if a deployment genuinely streams more than that through a tool.
const DestinationRefusedHint = allowPrivateAdvice + ". Cloud metadata addresses stay refused whatever it is set to"
DestinationRefusedHint is the next step to offer a model or an operator who has just been told a destination was refused.
It names the flag rather than describing the policy: the policy is already in the error, and what the reader does not have is the one string that changes it. Tier A has no such string, which the sentence says out loud rather than leaving a reader to try the flag and find it did nothing.
const GitLabDotComHost = "gitlab.com"
GitLabDotComHost is the canonical host for GitLab SaaS-only features.
const ScopeAPI = "api"
ScopeAPI is the GitLab scope that permits writes.
Only the write scope is named here, and deliberately: this package answers one question — can this token mutate GitLab — and the read scope is not part of that answer. internal/oauth owns the full scope vocabulary for the authorization layer; duplicating it here would be a second place to keep in step with GitLab.
Variables ¶
var ErrDestinationRefused = errors.New("outbound destination refused")
ErrDestinationRefused reports that this server declined to open a connection to an address, before any packet was sent to it.
It is a refusal by this server rather than an answer from anything upstream, which is the distinction a reader of the message needs: nothing was reached, nothing was disclosed, and the fix is configuration rather than credentials.
var ErrNoResponseCaptured = errors.New("no response was captured")
ErrNoResponseCaptured is what ResponseCapture.Decode returns when no response passed through the transport under the capture's context: the request was never made, or was made with another context.
var ErrResponseTooLarge = errors.New("gitlab response exceeded the maximum size")
ErrResponseTooLarge reports that a GitLab response body exceeded the client's size ceiling and was abandoned rather than buffered.
It surfaces from whatever was reading the body — the SDK's JSON decode, an io.Copy into a caller's writer — so a tool handler sees a failed call rather than a truncated success.
var ErrUnboundClient = errors.New("gitlab client is unbound: the handler belongs to a shared catalog and was not bound to a credential")
ErrUnboundClient is what every request through an unbound client fails with. A handler that reports it was called through the shared, unbound copy of a catalog instead of through a copy bound to a credential, which is a wiring defect and never a user error.
Functions ¶
func CheckCallerNamedInstance ¶
CheckCallerNamedInstance refuses an instance URL a caller named that this server would decline to dial anyway.
It exists to answer at the door instead of deep inside a tool call. The dialer is still the authority — this consults the same predicate and the same opt-out — and this is the friendlier half: an operator who pointed the header at their own private GitLab gets one 400 naming the flag, rather than a server that starts, admits the credential and fails every action.
It resolves nothing. A host spelled as a literal address is judged here; a host spelled as a name is left entirely to the dialer, which sees what the name resolved to and is the only check that can. Asking DNS a question per request at the door would put a resolver timeout in front of every caller and would still not be authoritative.
func DetectScopes ¶
DetectScopes queries the GitLab PAT self endpoint to retrieve the scopes of the currently authenticated token. Returns nil on failure or when the endpoint is unavailable (GitLab < 16.0), allowing graceful fallback to registering all tools.
func HTTPTransport ¶
func HTTPTransport(skipTLSVerify bool) http.RoundTripper
HTTPTransport returns the GitLab HTTP transport configured with the same TLS policy used by authenticated GitLab clients.
func IsCredentialRejection ¶
IsCredentialRejection reports whether err is GitLab judging the credential, as opposed to any of the many ways a request can fail without producing a verdict about it.
Only an explicit 401 or 403 counts, the same rule Client.CredentialRejected applies to its own probe. A transport error, a timeout, a 404 and a 5xx all mean the question went unanswered, and treating those as a rejection turns a GitLab that is briefly unreachable into a mass revocation.
func IsGitLabDotComURL ¶
IsGitLabDotComURL reports whether rawURL targets the canonical GitLab.com host.
func NarrowToTokenScope ¶
func NarrowToTokenScope(cfg *config.ServerConfig) bool
NarrowToTokenScope marks a server configuration read-only when the token it was built for cannot write, and reports whether it did. Both transports call it once the scopes are known: the HTTP pool per entry, since an entry is per token, and stdio once at startup for its single token. The catalog built from the configuration then withholds every write action and reports it as withheld by the token scope, rather than listing actions GitLab would refuse one by one with its own 403 (ADR-0018).
A configuration already read-only is left alone, so the operator's setting and the token's limit never contradict each other in the log, and unknown scopes narrow nothing, for the reason WriteCapable gives.
func ScopeSatisfied ¶
ScopeSatisfied checks whether requiredScopes are all present in the detected tokenScopes. If tokenScopes is nil (detection failed or disabled), returns true (allow all). If requiredScopes is empty, returns true (no requirement).
func WithClient ¶
WithClient returns ctx carrying the client every handler running under it must use.
It is the channel that lets one github.com/modelcontextprotocol/go-sdk/mcp.Server serve many credentials. A server built once per configuration shape registers handlers that capture the credential-less client from NewUnboundClient; the HTTP layer resolves the pool entry for each request and installs that entry's client here, and Client.For is what every handler reads it back through.
A nil client is not stored. "Bound to nothing" and "not bound" would then be indistinguishable to Client.For, and the difference decides whether a handler runs under the caller's credential or under the one that refuses every request.
func WriteCapable ¶
WriteCapable reports whether a token's scopes permit mutating GitLab.
Unknown scopes (nil: detection failed, was disabled, or the instance is too old to answer) count as write-capable. Assuming otherwise would silently strip every mutating tool from a deployment whose token is perfectly able to use them, and a wrong "no" is invisible — the tools are simply not there — while a wrong "yes" surfaces as GitLab's own 403 on the call that actually tried to write.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client wraps the official GitLab API client with project-specific configuration. It includes connection resilience: when GitLab is unreachable at startup, the server enters degraded mode and automatically recovers when connectivity is restored.
func ClientFrom ¶
ClientFrom returns the client bound to ctx, and whether there was one.
Client.For is what handlers use; this is for the few callers that must distinguish "no credential is bound" from "the fallback was returned", such as a middleware deciding whether it has anything to install.
func NewClient ¶
NewClient creates an authenticated GitLab client from the provided configuration. When cfg.SkipTLSVerify is true, TLS certificate verification is disabled (for self-signed certs). The client includes a resilience transport that enables automatic recovery when GitLab becomes available after being unreachable at startup.
func NewClientWithToken ¶
NewClientWithToken creates an authenticated GitLab client with explicit parameters. Unlike NewClient, it does not require a full config.Config and is designed for use in the server pool where each client has a unique token but shares the same base URL and TLS settings. The client includes a resilience transport that enables automatic recovery when GitLab becomes available after being unreachable.
func NewClientWithTokenRetries ¶
func NewClientWithTokenRetries(baseURL, token string, skipTLSVerify, disableRetries bool) (*Client, error)
NewClientWithTokenRetries is NewClientWithToken with the retry policy exposed: disableRetries turns off client-go's retryablehttp wrapper (RetryMax 5, linear backoff), which unit tests need — a pooled client probing a mock that answers 5xx otherwise sleeps through the whole credential-check deadline instead of reading the answer it already has.
func NewOAuthClientWithToken ¶
NewOAuthClientWithToken creates a GitLab client that authenticates with "Authorization: Bearer" instead of the PRIVATE-TOKEN header. The server pool uses it in oauth HTTP mode, where every credential arrives as a Bearer token: an OAuth access token (gloas-...) is ONLY valid as Bearer — GitLab rejects it in PRIVATE-TOKEN — while personal access tokens are valid in both schemes, so forwarding exactly as received is correct for every token kind the mode admits.
func NewUnboundClient ¶
NewUnboundClient returns a client for baseURL that carries no credential and refuses every request with ErrUnboundClient.
It exists for the catalog cached per configuration and shared by every pooled server. The catalog is built once, through the same ActionSpecs functions a bound client would be handed, and those inspect the client only for the instance class (GitLab.com or self-managed), which the URL answers. Building it with a real client would capture that client's credential in a process-wide cache; building it with this one captures nothing that can reach GitLab, so a handler served from the shared copy by mistake fails closed instead of running under someone else's token.
It panics when baseURL does not parse: the URL is a constant chosen by the caller, so a bad one is a programming error rather than a condition to handle.
func (*Client) CredentialRejected ¶
CredentialRejected reports whether GitLab actively refuses this credential.
It issues GET /api/v4/user through the raw health client rather than the SDK on purpose: client-go wraps requests in retryablehttp with RetryMax 5 and a linear backoff, which turns a refused connection or a struggling instance into seconds of stalling. A liveness question about a credential should be asked once and answered fast.
Only an explicit 401 or 403 counts as a rejection. Every other outcome — a transport error, a 404 from a stubbed instance, a 5xx — means no verdict was obtained and is reported as false, so callers fail open.
func (*Client) CurrentUser ¶
func (c *Client) CurrentUser(ctx context.Context) (*CurrentUserInfo, error)
CurrentUser returns the identity of the authenticated GitLab user. It calls the /user API endpoint and returns both the numeric ID and username.
func (*Client) CurrentUsername
deprecated
func (*Client) DetectEnterprise ¶
DetectEnterprise updates the client edition flag from /api/v4/version when GitLab exposes it, returning fallback when the field is absent or detection fails.
func (*Client) DetectTier ¶
DetectTier resolves the GitLab licensing tier from the instance license and stores it on the client. It calls the License API (GET /license, admin-only on self-managed) and maps the returned plan to a tier via edition.TierFromPlan.
When the license cannot be retrieved (non-admin token, CE/Free instance, or any API error) or reports no plan, it falls back to edition.Free. The resolved tier is stored and returned.
func (*Client) EnableLazyInit ¶
func (c *Client) EnableLazyInit()
EnableLazyInit enables lazy re-initialization on subsequent API calls. Called when startup Initialize() fails so that the server can recover automatically when GitLab becomes available again.
func (*Client) EnsureInitialized ¶
EnsureInitialized attempts lazy re-initialization if the client was not initialized at startup (e.g. GitLab was down). This allows automatic recovery when GitLab becomes available again. Thread-safe via initMu. Includes a 30-second cooldown between attempts to avoid hammering GitLab. Called automatically by [resilienceTransport] on every SDK request.
func (*Client) For ¶
For returns the client bound to ctx, or the receiver when ctx carries none.
The receiver is the fallback rather than the answer, which is what makes the shared-server arrangement fail closed: on a shape server the captured client is the unbound one, so a handler reached without a bound context refuses with ErrUnboundClient instead of running under whichever credential happened to build the shape. On stdio, and in every test that registers with a real client, no context ever carries one and the receiver is simply returned, so the call is invisible.
A nil receiver is allowed and returns whatever the context carries, so a caller holding no fallback can still resolve one.
func (*Client) Initialize ¶
Initialize validates GitLab connectivity via a direct HTTP health check (bypassing the SDK transport chain to avoid recursion). On success it marks the client as initialized and returns the GitLab version string.
func (*Client) IsEnterprise ¶
IsEnterprise reports whether the GitLab instance is Premium/Ultimate, derived from the resolved tier (tier >= Premium).
func (*Client) IsGitLabDotCom ¶
IsGitLabDotCom reports whether the client is configured for GitLab.com.
func (*Client) IsInitialized ¶
IsInitialized returns true if Initialize() completed successfully.
func (*Client) IsUnbound ¶
IsUnbound reports whether this client is the credential-less one, which refuses every request with ErrUnboundClient.
It exists so a surface can refuse early and say why. A resource read or a completion resolved to this client has not failed at GitLab and never will reach it: the request could not be attributed to a credential, which is a wiring defect on this side. Asked after Client.For, it is the question "did this request bring a credential", and answering it before the call is what keeps the caller from being told their token lacks access to something.
A nil client is not unbound: it is no client at all, which every reader already handles.
func (*Client) MarkInitialized ¶
func (c *Client) MarkInitialized()
MarkInitialized sets the initialized flag without running the full Initialize flow. Intended for test setups where the client is preconfigured with a token or mock credentials.
func (*Client) MarkInstanceCallerNamed ¶
func (c *Client) MarkInstanceCallerNamed()
MarkInstanceCallerNamed records that this client's instance was named by a caller rather than by the operator, which is the --allow-any-gitlab-url hatch, and puts its very first hop under tier B.
It is called by the server pool, which is the only place that knows: a client is built from a URL string, and the string looks identical whether --gitlab-url published it or a GITLAB-URL header did.
The default is the other way round because the alternative breaks every ordinary deployment: a client whose instance nobody marked is one the operator configured, and refusing those would refuse GitLab on localhost.
func (*Client) Ping ¶
Ping validates connectivity and authentication by calling the GitLab version endpoint. Returns the GitLab version string on success. Callers should wrap ctx with context.WithTimeout to bound the network round-trip.
func (*Client) SetEnterprise ¶
SetEnterprise marks the client as connected to a Premium/Ultimate instance. It sets the tier to Premium when v is true and Free when v is false; callers needing the Premium/Ultimate distinction should use Client.SetTier.
func (*Client) SetMaxResponseBytes ¶
SetMaxResponseBytes sets how much of a single response body this client will read. A value of zero or less removes the ceiling.
The ceiling applies from the next request onwards; a body already being read keeps the limit it started with.
func (*Client) SetOnUnauthorized ¶
func (c *Client) SetOnUnauthorized(fn func())
SetOnUnauthorized registers fn to run the first time GitLab answers a call made with this client's credential with 401. fn runs on the calling goroutine, once; a nil fn clears it.
type CurrentUserInfo ¶
CurrentUserInfo holds the identity of the authenticated GitLab user.
type ResponseCapture ¶
type ResponseCapture struct {
// contains filtered or unexported fields
}
ResponseCapture holds the body of the last response a request made under WithResponseCapture received, so that a handler can read from it a field GitLab sends and client-go's struct does not model, from the same bytes the SDK decoded, without a second request and without spelling the route itself.
It exists because the 1:1 surface has no room for a field GitLab sends and this server drops, and client-go models most of the API but not all of it: a note's `imported_from`, a member's `membership_state`, a key's `expires_at`. Before this the answer was a request of the handler's own, which duplicates the route client-go already spells and, for a list, its pagination too. A capture leaves every request as it was and adds one decode of bytes already in memory.
Safe for use from one goroutine at a time, which is what a handler is; the transport records under a lock because a retried request answers more than once, and the last answer is the one the SDK decoded.
func CapturedBody ¶
func CapturedBody(body []byte) *ResponseCapture
CapturedBody returns a capture already holding body, as if a request made under it had been answered with it. It is for a test of a reader that decodes captures, which would otherwise need a transport to hand it one.
func WithResponseCapture ¶
func WithResponseCapture(ctx context.Context) (context.Context, *ResponseCapture)
WithResponseCapture returns a context under which the SDK client records the body of every response it receives, and the capture to read it from. Pass the context to the SDK call with gl.WithContext.
func (*ResponseCapture) Decode ¶
func (c *ResponseCapture) Decode(v any) error
Decode unmarshals the captured body into v, the way the SDK unmarshalled it into its own struct. A body that does not decode is an error a handler reports rather than swallows: the SDK decoded the same bytes, so the fault is in the type v names.