Error Handling
GitLab MCP Server reports a failed call in words an AI assistant can act on: what failed, why, and, where the fix is known, what to do next. This page describes what a caller receives, how GitLab’s answers are classified, and the answers the server gives on its own: not-found results, argument errors and confirmations of destructive actions.
What a caller receives
Section titled “What a caller receives”A tool call that fails comes back as a tool result with isError: true, carrying one text block and no structuredContent. The text is the error message itself, not a card with fields. Successful results are described in Output Format.
A message about a GitLab request is built from up to five parts, always in this order:
<operation>: <classification> (<GitLab's message>). Suggestion: <hint>: <cause>| Part | What it is | When it appears |
|---|---|---|
| Operation | The handler’s own label for the call, such as issueList, fileCreate or create security attributes. It names what failed, and is not a tool to call | Always |
| Classification | The server’s reading of the failure, from the tables below | Always |
| GitLab’s message | GitLab’s own words, as the GitLab client renders the body: {message: ...}, or one {key: value} per key | When GitLab gave a message that says more than the status |
| Suggestion | The handler’s next step, naming any action by its canonical ID | When the handler knows the fix for this answer |
| Cause | The request GitLab refused, as METHOD https://host/api/v4/path: status followed by GitLab’s message, or what happened to a request that got no answer | Always, except when the server could not attribute the request to a credential |
A 404 carries no request line: its cause is 404 Not Found over REST and failed to execute GraphQL query: 404 Not Found over GraphQL, because the GitLab client answers every 404 with one shared error that records the status and nothing about the request.
Creating a file that already exists, for example, is answered:
fileCreate: bad request: check your input parameters ({message: A file with this name already exists}). Suggestion: the file may already exist. Use repository.file_update to modify an existing file, or verify the branch name: POST https://gitlab.example.com/api/v4/projects/42/repository/files/README.md: 400 {message: A file with this name already exists}The answers the server gives on its own, before anything reaches GitLab (an unknown action or parameter, a destructive action without confirmation, an action withheld from the session), are a sentence of prose instead, quoted below where each is described.
Error classification
Section titled “Error classification”GitLab’s answers
Section titled “GitLab’s answers”Every answer GitLab gives with an error status, over REST or GraphQL, is classified by its status:
| Status | Classification | What to do |
|---|---|---|
| 400 | bad request: check your input parameters | Correct the arguments; GitLab’s message, when there is one, names the field it rejected |
| 401 | unauthorized: either the token (GITLAB_TOKEN) is invalid or expired, or it is valid and lacks a permission this action needs, since some GitLab endpoints answer a missing permission with 401 rather than 403. If the token works for other calls, treat this as a permission refusal | Try the token on another call; see why a 401 names two causes |
| 401, the token refused | authentication failed: GitLab rejected the token (GITLAB_TOKEN) itself as invalid, expired, revoked or without the api or read_api scope, so renew or replace it | Create a new token with api, or read_api for a read-only surface |
| 403 | access denied: your token lacks the required permissions. This can mean: (1) missing API scope on the token, (2) insufficient project role (some operations require Maintainer or Owner), or (3) the feature is restricted by instance admin settings | Check the token’s scopes, your role on the project or group, and the instance’s settings. A fine-grained token’s refusal is described apart |
| 404 | not found: the requested resource does not exist, you lack access, or the feature requires a higher GitLab tier. Verify the ID/path is correct | Check the ID or path, that you can see the object, and the instance’s tier |
| 405 | method not allowed: the action cannot be performed on this resource in its current state | Check the object’s state: closed, merged, archived or protected |
| 409 | conflict: the resource already exists or there is a state conflict | Read the existing object before creating it again |
| 422 | validation failed: GitLab rejected the request due to invalid data | Read GitLab’s message for the value it rejected |
| 429 | rate limited: too many requests, please wait before retrying | Wait; the request has already been retried |
| 500 | GitLab internal server error: the server encountered an unexpected condition | Try again later, or ask the instance’s administrator |
| 502 | GitLab is temporarily unavailable (bad gateway): try again shortly | Wait and try again |
| 503 | GitLab is under maintenance or overloaded (service unavailable): try again shortly | Wait and try again |
| Any other | GitLab returned HTTP <status> | Read GitLab’s message and the cause |
An error GitLab reports inside a 200 answer has no status to classify; see GraphQL errors.
Requests GitLab did not answer
Section titled “Requests GitLab did not answer”These are checked before GitLab’s answer is looked for, in the order of the table, so a call the client canceled is reported as canceled even when the failure underneath looks like a broken connection:
| Situation | Classification | What to do |
|---|---|---|
| The client canceled the call, or abandoned the HTTP request carrying it | the request was canceled by the client | Nothing is wrong; send the call again if it is still wanted |
The call ran past a deadline, such as the action deadline (GITLAB_MCP_ACTION_TIMEOUT, 65 minutes by default) | the request exceeded its deadline and was canceled | Narrow the call (a smaller page, a shorter wait), or raise the deadline |
| HTTP mode could not tell which credential the request belongs to | this request could not be attributed to a credential and was not sent to GitLab; retry, and report it if it persists | Retry, and report it if it persists. The message carries no cause and no hint, since nothing reached GitLab |
| The server refused to connect to the address (outbound connections) | this server refused to connect to that address, so the request never left the process | The suggestion names --allow-private-instances for a GitLab or an object store on your own private network; cloud metadata addresses stay refused whatever it says |
| Connection refused | GitLab server is unreachable (connection refused). Check GITLAB_URL and whether the server is running | Check GITLAB_URL and that the instance is up |
| DNS failure | GitLab server hostname could not be resolved (DNS error). Check GITLAB_URL | Check the host name in GITLAB_URL and the machine’s DNS |
| Timeout | Request to GitLab timed out. The server may be overloaded or unreachable | Try again later, and check the network path to the instance |
| TLS handshake | TLS/SSL handshake failed. If using self-signed certificates, set GITLAB_MCP_SKIP_TLS_VERIFY=true | Prefer trusting the instance’s CA (TLS); skipping verification is for development only |
| Any other network error | network error reaching GitLab (Get), naming the HTTP method of the request that failed (Get, Post) | Check proxies and the network path to the instance |
| Anything else | unexpected error | Read the cause after it |
Why a 401 names two causes
Section titled “Why a 401 names two causes”GitLab answers 401 for two different things. Its API guard answers it for a credential it cannot use, and a family of REST routes answers it for a valid credential that lacks a permission: merging, cancelling auto-merge, approving and resetting approvals, adding to a merge train, push mirrors (GitLab’s remote mirrors API), access token reads, lists and rotation, external status checks, security settings, group SAML links, removing an award emoji, updating a group and linking a fork (upstream entry). Approving a merge request you opened, on an instance that prevents approval by the author, is the common one.
The status cannot tell the two apart, so the plain 401 sentence names both and ends with the test that separates them: if the same token works for other calls, the 401 is a permission refusal. It opens with “unauthorized” rather than “authentication failed”, because for a permission refusal authentication succeeded.
The server says the token itself was refused only when GitLab said so, in one of two ways:
- A REST body carrying the RFC 6750 code
invalid_token. GitLab’s API guard writes it for an expired, revoked or impersonation-disabled token, and nothing else in the REST API writes it. - Any
401from the GraphQL endpoint. That endpoint answers401only from its authentication checks, with{"errors":[{"message":"Invalid token"}]}, and refuses a field the caller may not see with a200, so its401cannot be a permission refusal. One of those checks is the scope: it authenticates a token only when the token carriesapiorread_api, and answers one carrying neither with that same body, where REST answers the same token403 insufficient_scope. That is why the sentence names the scope.
The opposite verdict cannot be read off an answer: a token GitLab has no record of at all is answered with the same bytes as a permission refusal, so a REST 401 without the code keeps the sentence that names both causes. In HTTP mode the same rule decides what a 401 does to the caller’s pooled credential; see Refused calls.
Fine-grained token refusals
Section titled “Fine-grained token refusals”A fine-grained personal access token carries a grant of named permissions, fixed when it is created, and GitLab judges every call against it after the token authenticates. Its refusal is not the ordinary 403, none of whose three causes is what is missing, so the server reads it first and describes it in GitLab’s terms. The evidence is GitLab’s own:
- Over REST, a
403whose body carries the codeinsufficient_granular_scope, with GitLab’s sentence as itserror_description. The code is the evidence, so a sentence the server cannot read is still described as a fine-grained refusal. - Over GraphQL, a mutation outside the grant is answered
200, with the mutation’s fieldnulland GitLab’s sentence as oneerrors[]entry. Each entry is read whole, including where the GitLab client’s achievement and scan profile services join several into one error with semicolons. - GitLab’s generic GraphQL refusal,
The resource that you are attempting to access does not exist or you don't have permission to perform this action, which a mutation declaring no fine-grained permission answers, is left as it is: GitLab answers every token with those words wherever a permission is missing.
| GitLab’s sentence | What the classification says |
|---|---|
Access denied: This operation requires a fine-grained personal access token with the following project permissions: [Merge Request: Approve]. | access denied: this call needs the fine-grained project permission [Merge Request: Approve], which the token was not granted, then that a grant cannot be changed, so the way out is a new fine-grained token that grants it or a classic token where the group does not refuse one, and that a classic token refused this way is under a group that requires fine-grained tokens |
Access denied: This operation doesn't support fine-grained personal access tokens. | access denied: GitLab declares no fine-grained permission for this operation, so no fine-grained personal access tokens can call it on this instance, then the classic token way out |
Access denied: Fine-grained personal access tokens are not yet supported. | access denied: fine-grained personal access tokens are not enabled for this token's user on this instance (GitLab's granular_personal_access_tokens feature flag), so GitLab refuses every call the token makes, then a classic token or the instance’s administrator |
404 Not Found, as a GraphQL errors[] entry | not found: GitLab could not find what this call names, or the token may not see it, then that a fine-grained token sees only the projects and groups its grant covers |
The description reaches the caller whichever way the error travelled, in front of the cause and never twice. No role, licence or owner hint follows it (corrective hints). Fine-grained Tokens covers what to grant, and the refusals the server itself gives such a token.
How a message is composed
Section titled “How a message is composed”An error message takes one of four forms, and the handler picks one by what it knows about the failure. The get actions of 22 domains answer a 404 with a result rather than an error (below).
<operation>: <classification>: <cause>, for a failure no detail of the handler’s improves. Here an nginx error page answered for GitLab; the page is not quoted, so the cause ends at the status:
list project service accounts: GitLab is temporarily unavailable (bad gateway): try again shortly: GET https://gitlab.example.com/api/v4/projects/42/service_accounts: 502<operation>: <classification> (<GitLab's message>): <cause>, for most writes. The parentheses appear only when GitLab’s message says more than the status: a bare {message: 403 Forbidden} adds nothing and is left out. An issue list refused for an expired token:
issueList: authentication failed: GitLab rejected the token (GITLAB_TOKEN) itself as invalid, expired, revoked or without the api or read_api scope, so renew or replace it ({error: invalid_token}, {error_description: Token is expired. You can either do re-authorization or token refresh.}): GET https://gitlab.example.com/api/v4/projects/42/issues: 401 {error: invalid_token}, {error_description: Token is expired. You can either do re-authorization or token refresh.}<operation>: <classification> (<GitLab's message>). Suggestion: <hint>: <cause>, when the handler knows the next step for this answer:
fileCreate: bad request: check your input parameters ({message: A file with this name already exists}). Suggestion: the file may already exist. Use repository.file_update to modify an existing file, or verify the branch name: POST https://gitlab.example.com/api/v4/projects/42/repository/files/README.md: 400 {message: A file with this name already exists}The suggestion form for one status, and the form with GitLab’s message for every other. Protecting a branch without the role for it:
branchProtect: access denied: your token lacks the required permissions. This can mean: (1) missing API scope on the token, (2) insufficient project role (some operations require Maintainer or Owner), or (3) the feature is restricted by instance admin settings. Suggestion: protecting branches requires Maintainer or Owner role: POST https://gitlab.example.com/api/v4/projects/42/protected_branches: 403 {message: 403 Forbidden}Not-found answers
Section titled “Not-found answers”The get actions of 22 domains answer GitLab’s 404 with an informational result instead of an error: award emoji, badges, branches, dependency firewall, deployments, environments, files, groups, group service accounts, labels, merge requests, milestones, Orbit, packages, pipelines, projects, project service accounts, releases, snippets, tags, users and wikis. A domain answers this way for every get variant it has: project and group badges alike, and each award emoji target.
branch.get for a branch that does not exist answers:
## ❓ Branch Not Found
The branch **"feature/old" in project 42** does not exist or is not accessible with your current permissions.
---💡 **Next steps:**- Use action 'branch.list' to list the project's branches- Verify the branch name is spelled correctly (case-sensitive)- The result still carries
isError: true, since the call could not do what was asked, and nostructuredContent. - The server logs it at INFO as a completed call marked
is_error: true, not at ERROR as a failure. - The identifier is written as the caller wrote it, with the documented parameter aliases resolved and a number printed whole, and escaped so it cannot add a link or a tag to the card.
- In a fine-grained session, a not-found answer of an action that reads GitLab over GraphQL also carries a note that not found may mean the token cannot see the object (what an empty answer can mean).
Every other action answers a 404 with an ordinary error: the not found classification, usually followed by a suggestion naming the action to check with.
Corrective hints
Section titled “Corrective hints”Where the fix for an error is known, the handler attaches it. Some hints follow only one HTTP status (a 422 validation failure, a 404, a 409), and any other status gets GitLab’s message with no suggestion; others follow every answer, which is what a GraphQL error reported inside a 200 response needs, since it carries no status code. A GraphQL refusal answered with an error status carries one, and a hint bound to a status reads it there as it does over REST. The suggestion travels in the error text as Suggestion: ..., so the AI assistant can correct its request without additional API calls. A hint names an action by its canonical ID (branch.list), which every tool surface resolves, rather than by a tool name, which only one of them registers.
A hint is left out where it cannot be right:
- A request that could not be attributed to a credential carries no hint: a hint advises about GitLab’s state, and GitLab was never asked.
- A destination this server refused gets one suggestion whatever the handler wrote: the
--allow-private-instancesflag, the one thing that changes that outcome. - A refusal of a fine-grained token’s grant drops the handler’s hint, since a hint written for GitLab’s ordinary refusal names a role, a licence, an owner or an administrator, and none of them is what is missing.
- A hint naming a role, a licence or an owner follows only an answer that can be a permission refusal: a REST
401or403whose body carries no RFC 6750 error code and does not refuse the account itself (blocked, deactivated, pending approval, an internal user, the Terms of Service not accepted, the primary email unconfirmed, the password expired). It never follows the verdict that GitLab refused the token, which it would contradict.
Permission hints on a 401
Section titled “Permission hints on a 401”Every action whose GitLab route refuses a permission with 401 carries a suggestion naming the permission: the route family listed above. Two kinds of action differ. The four group SAML link actions (group.saml_link_add, group.saml_link_delete, group.saml_link_get, group.saml_link_list) add their hint to every error, a rejected token included. The self-rotations (access.token_personal_rotate_self, access.token_project_rotate_self, access.token_group_rotate_self) add a hint about the calling token to every 401, which agrees with the verdict that the token was refused rather than contradicting it.
Routes whose 401 and 403 mean different things
Section titled “Routes whose 401 and 403 mean different things”Some routes answer one cause with 401 and another with 403, and their handlers read the status to tell them apart, so the hint follows the cause:
| Actions | A 401 means | A 403 means |
|---|---|---|
external_status_check.list_project_mr_checks, external_status_check.set_project_mr_status, external_status_check.retry_project | The project’s namespace lacks the Ultimate licence | Your role on the merge request falls short |
project.create_fork_relation | The namespace of project_id is not one forked_from_id may be forked into | You lack the Owner role on project_id, the right to create forks in its top-level namespace (Maintainer or Owner when that is a group), or the right to fork forked_from_id |
project.security_settings_get, project.security_settings_update, group.security_settings_update | Your role falls short | The instance’s licence lacks the feature (secret push protection needs Ultimate); on a project update, also an archived project or a setting the instance enforces |
merge_train.add | You may not merge the merge request into its target branch | The read check every merge train route runs first |
GraphQL errors
Section titled “GraphQL errors”The domains that read GitLab over GraphQL report a failure in one of three ways:
-
A refusal with an error status is classified by its status like any REST answer, and a
401from GraphQL always names the token. The GitLab client appends(GraphQL errors: <message>, <message>)to the cause, listing everyerrors[].messageof the body. The server keeps that list on one line, caps it at 2048 bytes as a whole, and drops it when the body has a top-level key other thandata,errorsandextensions, since GitLab did not compose such a body. A404arrives asfailed to execute GraphQL query: 404 Not Found, without the list. -
An error inside a
200answer has no status. A handler that reads the top-levelerrors[]reports<operation> GraphQL errors: <message>; <message>, and one that reads a mutation’s ownerrorsfield reports<operation> mutation errors: <message>; <message>. Where a handler wraps such an error, the classification in front readsunexpected error, because there is no status to classify; GitLab’s words after it are the part to read:create security attributes: unexpected error. Suggestion: verify namespace_id and category_id; requires permission on a Premium or Ultimate namespace: securityAttributeCreate mutation errors: <GitLab's message> -
A refused mutation answers with its payload
nulland GitLab’s reason among the top-levelerrors[]. Mutation handlers read the payload beside those entries, so a refusal is reported with GitLab’s reason rather than as a change that happened.
Argument errors
Section titled “Argument errors”Arguments are checked before anything is sent to GitLab, in up to three places, and each words its refusal its own way:
- The tool’s input schema. Arguments the schema refuses (a wrong type, a value outside an enum, a property the schema does not declare where it is closed) are refused before the server’s handler runs, with a message that starts
validating "arguments":. An individual tool’s schema is closed, a meta tool’s is closed underGITLAB_MCP_META_PARAM_SCHEMA=full, and so is the envelope ofgitlab_execute_action, which is why an action’s parameter written besideactionrather than insideparamsis refused this way. - The surface’s own check. On the dynamic surface:
gitlab_execute_action/<action>: invalid params., followed byUnknown params: ..., aDid you mean ...?suggestion,Missing required params: ...and theValid params: ...(see Dynamic toolset). On the meta surface:gitlab_issue/get: missing required params: issue_iid. Put action-specific fields under params.,'params' is required for this action. Required params: ..., or, for an action the tool does not have,gitlab_issue: unknown action "x". Valid actions: .... - The action’s own decoding. Every key must be a parameter of the action, and any other is refused rather than dropped:
invalid params for this action: json: unknown field "foo", which the meta surface opens with the tool and action (gitlab_issue/get: ...). The reservedconfirmkey is taken out first. Where the schema is open, a few documented aliases are translated to their canonical names before this check (mr_iidtomerge_request_iid,commit_idtocommit_sha), and a number sent as a string is converted; a closed schema refuses an alias in the first step.
A required value that arrives empty or zero is refused with a message naming the exact parameter, aimed at the names models confuse (milestone_id for milestone_iid, branch for branch_name, iid for merge_request_iid):
milestoneGet: milestone_iid is required (must be > 0). Ensure you use the exact parameter name 'milestone_iid' as documented in the tool descriptionbranchCreate: branch_name is required (must be non-empty). Ensure you use the exact parameter name 'branch_name' as documented in the tool descriptionA handler that checks a value against a fixed set names the values it accepts, such as invalid status "approve", must be one of: approved, rejected.
Destructive actions and confirmation
Section titled “Destructive actions and confirmation”An action the catalog classifies as destructive asks for confirmation before it runs; Security explains which actions those are and why. Three things are decided first: an action a fine-grained session may not run is refused, read-only mode has removed every write, and safe mode previews a write instead of running it, so it asks nothing.
On every surface a destructive call proceeds on the first of these that holds:
GITLAB_MCP_YOLO_MODEis truthy (1,trueoryes), or, when it is unset,AUTOPILOTis.- The call carries
"confirm": true(insideparamson the meta surface, at the top level of the arguments on the dynamic one). - On the meta and individual surfaces, the client supports elicitation and the user approves the prompt,
Confirm gitlab_branch/delete? This action may be irreversible.On protocol 2026-07-28 the question travels as an input request the client answers by sending the call again (Elicitation).
Otherwise it fails closed and nothing reaches GitLab: Confirm gitlab_branch/delete? This action may be irreversible. The connected client cannot prompt for confirmation. Re-send with confirm=true only after the user explicitly approves this operation.
When the user is asked, every answer but an approval returns an error result, since the action produced nothing:
| Answer | Result |
|---|---|
| Declined | The user declined this operation. Do not retry it; ask what they would like instead. |
| Dismissed without an answer | The confirmation was dismissed without an answer. Nothing was changed; you may ask again if the user still wants this. |
| Answered without confirming | Operation canceled by user. |
| The exchange failed | Confirmation failed: <reason>. The action was not executed. Re-send with "confirm": true only after the user explicitly approves this operation. |
A confirmation state the server did not issue is a protocol fault rather than an answer, and is refused as a JSON-RPC invalid-params error.
The dynamic surface asks nothing. Unless GITLAB_MCP_YOLO_MODE or AUTOPILOT lets it through as in step 1, gitlab_execute_action refuses a destructive action sent without a top-level confirm: true before it dispatches anything: gitlab_execute_action: action "branch.delete" is destructive. Re-send with confirm=true only after the user explicitly approves this operation. It never prompts, so on the default surface the confirmation is the caller’s confirm: true or the operator’s setting, and a refusal recorded as needs_confirmation means the same on every surface. Up to 3.1.0 this surface read neither setting.
What an error leaves out
Section titled “What an error leaves out”Error text is built to let a model correct its request without carrying anything it should not:
- GitLab’s message is bounded. It is flattened onto one line, with control characters removed, and cut at 2048 bytes, in the parentheses and in the cause alike. The bound is sized to keep whole the longest message GitLab is known to write for a mistake the caller can correct, an Orbit query refused with the list of every relationship type the graph holds. GitLab’s messages often quote input somebody else chose, a branch name or a title, and with its line breaks intact such a quote could add structure to the text a model reads.
- A body GitLab did not compose is dropped, not quoted: one that is not JSON (a proxy’s error page, a captive portal), and a JSON object with a top-level key GitLab’s error body never has (anything but
message,erroranderror_description). Such pages carry internal host names and request identifiers. The status and the classification still say what happened. One body outside that set is quoted: Workhorse’s refusal of an Orbit query, answered toPOST /api/v4/orbit/querywith exactly a stringcodeand a stringmessage, when the code iscompile_errororvalidation_error, since it is the only account of what is wrong with the query. Any other code (execution_error,internal_error,timeout,quota_exhausted), any further key, and the same body on any other route are dropped. - The request line is the only part of the request repeated. The cause names the method, scheme, host and path, so what the call put in the path (a project path, an IID, a file path) appears there. The query string, the request body, the headers and the token never do, and neither does GitLab’s request ID.
- A handler’s own validation message may quote the value it refused, an enumerated value or a ref name, so the caller sees what it sent.
The tool call failed line the server logs carries the same bounded text.
Retries
Section titled “Retries”The server does not sort errors into transient and permanent, and it never retries a tool call. The one retry is the GitLab client’s own: client-go, through go-retryablehttp, re-sends a failed request at most twice before the error reaches the handler, under the policy this server sets:
| Failure | Re-sent | Wait before each re-send |
|---|---|---|
429 Too Many Requests | For every method | About 0.7 seconds, then 1.4, or until GitLab’s RateLimit-Reset when that is later, never more than 5 seconds |
A 5xx other than 501, or an answer with no status | Only for a method that is safe to repeat (GET, HEAD, PUT, DELETE, OPTIONS): never a POST, so never a GraphQL request | About 0.7 seconds, then 1.4 |
| A connection that failed before the request was sent: refused, a failed dial, a temporary DNS failure (not a name that does not exist), a TLS handshake timeout | For every method | About 0.7 seconds, then 1.4 |
| Anything else, including a call that was canceled or ran past its deadline | No | No wait: the error is reported at once |
Each wait adds up to 0.3 seconds of jitter, and a RateLimit-Reset further out than 5 seconds is waited for only 5 seconds, so the 429 is re-sent before the window reopens and is usually what reaches the assistant. What that means for the assistant reading the error:
- A
429, or a5xxon a read, that reaches it has already been sent three times, so wait before trying again rather than at once. - A write answered with a
5xxwas not re-sent, because GitLab may have acted on it before failing to answer. Read the object before writing again, or the retry can create a duplicate. - Any other
4xxdescribes the request, and sending it again gets the same answer: fix the arguments, the token or the role the message names.
In HTTP mode the server’s own rate limit refuses a call before it reaches GitLab, with an error result of its own; see HTTP Server Mode.
Example error scenarios
Section titled “Example error scenarios”Permission refused with 401
Section titled “Permission refused with 401”GitLab answers some permission refusals with 401 rather than 403: approving a merge request you opened on an instance that prevents approval by the author, or merging without push access to the target branch. The status alone cannot say which cause applies, so the classification names both, and the handler’s suggestion names the permission:
mrApprove: unauthorized: either the token (GITLAB_TOKEN) is invalid or expired, or it is valid and lacks a permission this action needs, since some GitLab endpoints answer a missing permission with 401 rather than 403. If the token works for other calls, treat this as a permission refusal. Suggestion: you may be the MR author (self-approval not allowed) or lack sufficient permissions: POST https://gitlab.example.com/api/v4/projects/42/merge_requests/7/approve: 401 {message: 401 Unauthorized}GitLab’s {message: 401 Unauthorized} only repeats the status, so it is not repeated in parentheses.
Permission denied
Section titled “Permission denied”A 403 the handler has no hint for carries the three possible causes, and GitLab’s {message: 403 Forbidden} in the cause:
issueCreate: access denied: your token lacks the required permissions. This can mean: (1) missing API scope on the token, (2) insufficient project role (some operations require Maintainer or Owner), or (3) the feature is restricted by instance admin settings: POST https://gitlab.example.com/api/v4/projects/42/issues: 403 {message: 403 Forbidden}Not found, outside the 22 domains
Section titled “Not found, outside the 22 domains”issue.get is not one of the actions that answer with a not-found card, so its 404 is an error, with a suggestion and the request-less cause every 404 has:
issueGet: not found: the requested resource does not exist, you lack access, or the feature requires a higher GitLab tier. Verify the ID/path is correct. Suggestion: verify project_id and issue_iid; use issue.list to see existing issues in the project: 404 Not FoundFrequently asked questions
What does a failed tool call return?
A tool result with isError: true, one text block holding the error message, and no structuredContent. The message names the operation that failed and the server's classification of the failure, then GitLab's own message in parentheses when it gave one, then Suggestion: and a next step when the handler knows one, and it ends with the request GitLab refused (METHOD URL: status). The get actions of 22 domains answer a 404 with a short not-found card instead.
How does the server classify GitLab API errors?
By the answer GitLab gave. Each HTTP status has a fixed sentence: 400 bad request, 403 access denied with its three possible causes, 404 not found, 405 not allowed, 409 conflict, 422 validation failed, 429 rate limited, and 500, 502 and 503 for an instance in trouble. A 401 names both of its causes, an unusable token and a missing permission, unless GitLab said the token itself was refused (the invalid_token code, or any 401 from GraphQL). A refusal of a fine-grained token's grant is described in GitLab's own terms. A request that got no answer is classified by what happened to it: canceled, past its deadline, refused by this server's destination check, or a network failure (connection refused, DNS, timeout, TLS).
Why does a 404 sometimes not look like an error?
The get actions of 22 domains answer GitLab's 404 with an informational result: a card saying the object does not exist or is not accessible with your current permissions, followed by next steps such as the list action to call. It still carries isError: true, and the server logs it at INFO as a completed call rather than at ERROR as a failure. Every other 404 is an ordinary error whose classification says the resource does not exist, you lack access, or the feature needs a higher GitLab tier.
Should the AI assistant retry a failed operation?
Not as a reflex. The server does not sort errors into transient and permanent, and it never retries a tool call. The GitLab client underneath re-sends a failed request at most twice before the error reaches the tool: after a 429, after a 5xx for a request that is safe to repeat (never a POST), and after a connection that failed before anything was sent. A 429 or a 5xx that reaches the assistant has therefore usually been retried already, so wait before trying again. Any other 4xx describes the request, so fix the arguments, the token or the role the message names instead.