Connect GitLab with an Organization Token#

Overview#

An org token is a single GitLab access token, owned by a GitLab service account rather than a person, that connects everyone in your Roboto organization to GitLab’s MCP server. With an org token, the AI can use GitLab in every conversation in your organization: AI Chat for members who haven’t connected GitLab themselves, and conversations that run as a service user. Those are agents that triggers launch, such as one that triages every upload (see Auto-Triage Robotics Data), and @Roboto conversations in Slack (see Roboto in Slack).

This guide is for org admins, and most of the work happens in GitLab. You create a service account, give it access to the right projects, and create a token for it. Then you store the token as a Roboto secret and attach it to the GitLab server.

For how MCP connections work in general, see MCP Connections. To connect GitLab with your own GitLab account instead, follow Connect MCP Servers to Roboto.

How Roboto uses the org token#

  • In the web app, a member who connected GitLab with the Connect button acts as themselves. Roboto always tries their personal connection first.

  • If that personal connection stops working (it expired and can’t be renewed, its refresh failed, or GitLab rejects it), Roboto uses the org token for that member instead. When this happens partway through a conversation, the AI tells the member that the call ran as the service account.

  • Everyone else, including members without a personal connection and service users, acts as the GitLab service account.

  • A Slack conversation runs as a service user, so it always uses the org token. Any member who mentions @Roboto can have the AI use GitLab as the service account, even if they connected GitLab themselves.

  • Roboto sends the token to your GitLab instance’s MCP server at https://<gitlab-host>/api/v4/mcp.

  • Everyone acting as the service account shares its GitLab rate limits. GitLab.com can limit MCP requests per user by the group’s plan (60 a minute on Free, 600 on Premium and Ultimate), and self-managed administrators can set their own limit.

Everyone acting as the service account shares its access, so give that account access only to what the AI should see and change. See Security notes.

Requirements#

  • GitLab 19.2 or later. GitLab’s MCP server accepts fine-grained personal access tokens starting with 19.2. GitLab.com meets this requirement; self-managed and GitLab Dedicated instances must run 19.2 or later.

  • GitLab admin access. On GitLab.com, you must be an Owner of the top-level group. On self-managed GitLab, you need instance Administrator access: Step 2 changes an instance setting, so an instance administrator must do it even if group Owners are allowed to create service accounts.

  • A Roboto org admin role, to attach the token to the GitLab server.

  • The GitLab MCP server enabled for your organization in Roboto. If Settings → AI → MCP Connections has no GitLab card, contact support@roboto.ai.

  • curl and jq on the machine where you run the token script below.

Step 1: Create a service account and add it as a member#

A GitLab service account is a non-human user for automation: it authenticates only with personal access tokens, can’t sign in, and doesn’t use a paid seat. Because no person owns it, the org token keeps working when people leave or change roles.

  1. Create the service account:

    • GitLab.com: in the top-level group, go to Settings → Service accounts and add a service account, for example roboto-ai.

    • Self-managed: an instance administrator creates it in Admin → Settings → Service accounts. If administrators allow top-level group Owners to create service accounts, an Owner can instead create one from the group’s Settings → Service accounts.

  2. Add the service account as a member of each group or project the AI should use. Creating the account inside a group leaves it outside the group’s membership, so this is a separate step: open the group or project, go to Manage → Members, and invite the service account by its username.

  3. Give it the lowest role that covers what the AI should do:

    The AI should…

    Role

    Read code, issues, merge

    Reporter

    requests, and pipelines

    Also create branches, commits,

    Developer

    and merge requests

Until GitLab fixes the issue described in Security notes, the service account’s membership and role, rather than the token’s permissions, set the limit on what the AI can do.

Step 2: Enable MCP client access in GitLab#

GitLab rejects MCP clients unless MCP client access is on. Depending on your GitLab version and when the group was created, it may already be on; check it:

  • GitLab.com: a top-level group Owner goes to the group’s Settings → General → Permissions and group features, and under MCP client access selects Allow connection to GitLab, then saves the changes.

  • Self-managed: an instance administrator goes to Admin → Settings → General → Visibility and access controls, and under MCP client access selects Allow connection to GitLab, then saves the changes.

On GitLab.com, do this after you’ve added the service account as a member (Step 1). GitLab.com caches, for one hour per user, whether the user belongs to a group with MCP enabled, so a call made before the account was a member can keep failing for up to an hour. To clear the cache, see Troubleshooting.

Step 3: Create a temporary legacy token#

GitLab’s MCP server accepts only fine-grained personal access tokens, but GitLab’s web UI can create only legacy personal access tokens (legacy tokens) for a service account. To get a fine-grained token, you create a short-lived legacy token and use it once to create the fine-grained one.

  1. Open the service account’s personal access tokens: on the Service accounts page where you created it, select the vertical ellipsis (⋮) next to the account, then Manage access tokens.

  2. Select Add new token, and create a token with only the api scope and the earliest expiry date GitLab allows, such as tomorrow.

  3. Copy the token into a file only you can read, for example:

    install -m 600 /dev/null legacy-token.txt
    # Paste the token into legacy-token.txt with an editor; don't put it on the command line.
    

Step 4: Create the fine-grained token#

The script below uses the legacy token once to call GitLab’s POST /api/v4/user/personal_access_tokens endpoint. Because the legacy token belongs to the service account, the new fine-grained token does too. The script:

  • shows who owns the legacy token, and stops unless it’s a service account, so you can’t create the token on your personal account by mistake;

  • looks up project and group IDs from their paths (--project acme/firmware), or accepts numeric IDs;

  • grants the MCP permission plus the project permissions from a preset, or the list you pass with --permissions;

  • writes the new token to a file with mode 600, and never prints either token or passes one on the command line;

  • with --verify, checks that GitLab’s MCP server accepts the new token.

create-gitlab-mcp-token.sh
#!/usr/bin/env bash
# Mint a fine-grained GitLab personal access token for GitLab's MCP server,
# owned by a service account, using a temporary legacy token (api scope).
set -euo pipefail

usage() {
  cat <<'EOF'
Usage: create-gitlab-mcp-token.sh --expires YYYY-MM-DD (--project P | --group G)... [options]

  --host HOST                GitLab host (default: $GITLAB_HOST or gitlab.com)
  --legacy-token-file FILE   File holding the temporary legacy token. Otherwise
                             $GITLAB_LEGACY_TOKEN is used, or you are prompted.
  --project ID|PATH          Project the token may act on (repeatable)
  --group ID|PATH            Group the token may act on (repeatable)
  --preset NAME              read-only (default) or read-write
  --permissions LIST         Comma-separated permissions; replaces the preset
  --expires YYYY-MM-DD       Expiry date of the new token (required)
  --name NAME                Token name (default: roboto-mcp)
  --out FILE                 Where to write the new token (default: ./gitlab-mcp-token.txt)
  --verify                   Call the MCP server with the new token afterwards
  --allow-non-service-account
                             Proceed even if the legacy token's owner is not a
                             service account (not recommended)
EOF
}

die() { echo "error: $*" >&2; exit 1; }

for tool in curl jq; do
  command -v "$tool" >/dev/null 2>&1 || die "$tool is required but not installed"
done

host="${GITLAB_HOST:-gitlab.com}"
token_file=""
projects=()
groups=()
preset="read-only"
permissions=""
expires=""
name="roboto-mcp"
out="./gitlab-mcp-token.txt"
verify=false
allow_human=false

need_arg() { [[ $# -ge 2 && -n "$2" ]] || die "$1 needs a value"; }

while [[ $# -gt 0 ]]; do
  case "$1" in
    --host) need_arg "$@"; host="$2"; shift 2 ;;
    --legacy-token-file) need_arg "$@"; token_file="$2"; shift 2 ;;
    --project) need_arg "$@"; projects+=("$2"); shift 2 ;;
    --group) need_arg "$@"; groups+=("$2"); shift 2 ;;
    --preset) need_arg "$@"; preset="$2"; shift 2 ;;
    --permissions) need_arg "$@"; permissions="$2"; shift 2 ;;
    --expires) need_arg "$@"; expires="$2"; shift 2 ;;
    --name) need_arg "$@"; name="$2"; shift 2 ;;
    --out) need_arg "$@"; out="$2"; shift 2 ;;
    --verify) verify=true; shift ;;
    --allow-non-service-account) allow_human=true; shift ;;
    -h|--help) usage; exit 0 ;;
    *) usage >&2; die "unknown option: $1" ;;
  esac
done

host="${host#https://}"
host="${host%/}"
[[ "$expires" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || die "--expires must be YYYY-MM-DD"
[[ ${#projects[@]} -gt 0 || ${#groups[@]} -gt 0 ]] || die "pass at least one --project or --group"
[[ -e "$out" ]] && die "$out already exists; choose another --out or remove it"

if [[ -z "$permissions" ]]; then
  read_perms="read_project,read_repository,read_code,read_commit,read_branch,read_pipeline,read_work_item,read_merge_request"
  case "$preset" in
    read-only) permissions="$read_perms" ;;
    read-write) permissions="$read_perms,create_work_item,update_work_item,create_merge_request,update_merge_request,create_branch,create_commit" ;;
    *) die "unknown preset: $preset (use read-only or read-write)" ;;
  esac
fi

# Read the legacy token without echoing it.
if [[ -n "$token_file" ]]; then
  [[ -r "$token_file" ]] || die "cannot read $token_file"
  IFS= read -r legacy_token <"$token_file" || true
elif [[ -n "${GITLAB_LEGACY_TOKEN:-}" ]]; then
  legacy_token="$GITLAB_LEGACY_TOKEN"
else
  [[ -t 0 ]] || die "no legacy token: use --legacy-token-file or GITLAB_LEGACY_TOKEN"
  IFS= read -rs -p "Legacy token (input hidden): " legacy_token
  echo >&2
fi
legacy_token="${legacy_token//[[:space:]]/}"
[[ -n "$legacy_token" ]] || die "the legacy token is empty"

# Pass tokens to curl through header files so they never appear in `ps` output.
umask 077
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
printf 'Authorization: Bearer %s\n' "$legacy_token" >"$workdir/legacy.hdr"

# api METHOD PATH [JSON_BODY] -> prints the response body; exits on HTTP errors.
api() {
  local method="$1" path="$2" body="${3-}" response status
  local args=(-sS -X "$method" -H "@$workdir/legacy.hdr" -w '\n%{http_code}')
  if [[ -n "$body" ]]; then
    args+=(-H "Content-Type: application/json" --data-binary "$body")
  fi
  response="$(curl "${args[@]}" "https://$host/api/v4$path")" || die "request to $host failed"
  status="${response##*$'\n'}"
  response="${response%$'\n'*}"
  if [[ "$status" != 2* ]]; then
    local message
    message="$(jq -r '.message // .error_description // .error // empty | tostring' <<<"$response" 2>/dev/null || true)"
    die "$method $path returned HTTP $status: ${message:-$response}"
  fi
  printf '%s' "$response"
}

# resolve_id KIND VALUE -> numeric ID (KIND is projects or groups)
resolve_id() {
  if [[ "$2" =~ ^[0-9]+$ ]]; then
    echo "$2"
  else
    api GET "/$1/$(jq -rn --arg p "$2" '$p | @uri')" | jq -r '.id'
  fi
}

me="$(api GET /user)"
echo "Legacy token belongs to: $(jq -r '"\(.username) (id \(.id), bot: \(.bot))"' <<<"$me")"
if [[ "$(jq -r '.bot' <<<"$me")" != "true" ]]; then
  "$allow_human" || die "this token belongs to a regular user, not a service account. Create the legacy token on the service account, or pass --allow-non-service-account."
  echo "warning: continuing with a token owned by a regular user" >&2
fi

project_ids=()
for p in ${projects[@]+"${projects[@]}"}; do
  id="$(resolve_id projects "$p")"
  echo "Project $p -> $id"
  project_ids+=("$id")
done
group_ids=()
for g in ${groups[@]+"${groups[@]}"}; do
  id="$(resolve_id groups "$g")"
  echo "Group $g -> $id"
  group_ids+=("$id")
done

to_json_ints() { if [[ $# -eq 0 ]]; then echo '[]'; else printf '%s\n' "$@" | jq -s 'map(tonumber)'; fi; }
body="$(jq -n \
  --arg name "$name" \
  --arg expires "$expires" \
  --arg perms "$permissions" \
  --argjson pids "$(to_json_ints ${project_ids[@]+"${project_ids[@]}"})" \
  --argjson gids "$(to_json_ints ${group_ids[@]+"${group_ids[@]}"})" \
  '{
    name: $name,
    expires_at: $expires,
    granular_scopes: [
      {access: "user", permissions: ["execute_mcp_tool"]},
      ({access: "selected_memberships", permissions: ($perms | split(",") | map(select(length > 0)))}
        + (if ($pids | length) > 0 then {project_ids: $pids} else {} end)
        + (if ($gids | length) > 0 then {group_ids: $gids} else {} end))
    ]
  }')"

created="$(api POST /user/personal_access_tokens "$body")"
new_token="$(jq -r '.token // empty' <<<"$created")"
[[ -n "$new_token" ]] || die "GitLab did not return a token: $created"
printf '%s\n' "$new_token" >"$out"
echo "Created token $(jq -r '"\(.name) (id \(.id), expires \(.expires_at), granular: \(.granular))"' <<<"$created")"
echo "Token written to $out (mode 600). It is not shown again."

if "$verify"; then
  printf 'Authorization: Bearer %s\n' "$new_token" >"$workdir/new.hdr"
  init='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"1"}}}'
  response="$(curl -sS -X POST -H "@$workdir/new.hdr" -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" -w '\n%{http_code}' \
    --data-binary "$init" "https://$host/api/v4/mcp")" || die "request to the MCP server failed"
  status="${response##*$'\n'}"
  if [[ "$status" == 200 && "$response" == *serverInfo* ]]; then
    echo "MCP check passed: the server accepted the new token."
  else
    echo "warning: MCP check failed (HTTP $status): ${response%$'\n'*}" >&2
    echo "See the troubleshooting section of the Roboto guide." >&2
  fi
fi

echo
echo "Next: revoke the temporary legacy token now. On the Service accounts page, select the vertical"
echo "ellipsis next to the account > Manage access tokens, then the vertical ellipsis next to the token > Revoke."

Save it as create-gitlab-mcp-token.sh, then run it. This example creates a read-only token for two projects on GitLab.com:

bash create-gitlab-mcp-token.sh \
  --legacy-token-file legacy-token.txt \
  --project acme/firmware \
  --project acme/fleet-config \
  --preset read-only \
  --expires 2027-06-30 \
  --verify

For self-managed GitLab, add --host gitlab.example.com or set GITLAB_HOST. To list every option, run bash create-gitlab-mcp-token.sh --help.

Permission presets#

Every token gets execute_mcp_tool, which lets it call GitLab’s MCP server. The preset adds permissions on the projects and groups you pass:

Preset

Permissions

The AI can

read-only

read_project, read_repository,

Read code, commits, branches,

(default)

read_code, read_commit, read_branch, read_pipeline, read_work_item, read_merge_request

issues, merge requests, and pipelines.

read-write

Everything in read-only, plus create_work_item, update_work_item, create_merge_request, update_merge_request, create_branch, create_commit

Also create and update issues and merge requests, comment on them, and create branches and commits.

Notes:

  • create_work_item also covers commenting on issues and merge requests.

  • A --group grant should cover the group’s projects. If tools for merge requests, branches, or commits fail with a permission error on your GitLab version, mint the token again with --project for each project instead.

  • Your GitLab instance caps the token’s lifetime, at 365 days by default. Pick an expiry date within that cap, and rotate the token before it (see Rotate the token).

  • For the AI to write, Roboto must also enable GitLab’s write tools, which are off by default; see Step 7.

Step 5: Revoke the legacy token#

You no longer need the legacy token, and its api scope grants full read and write access to GitLab’s API. Revoke it now: open the service account’s access tokens as in Step 3, select the vertical ellipsis (⋮) next to the legacy token, then Revoke. Then delete legacy-token.txt. Revoking it leaves the fine-grained token working.

Step 6: Store the token as a Roboto secret#

Store the fine-grained token as a secret in your Roboto organization:

  1. In the Roboto web app, go to Settings → Secrets.

  2. Add a secret, for example gitlab-mcp-token, and paste the contents of gitlab-mcp-token.txt as its value.

Or use the Python SDK, which reads the token from the file:

from pathlib import Path

from roboto.domain.secrets import Secret

token = Path("gitlab-mcp-token.txt").read_text().strip()
Secret.create(name="gitlab-mcp-token", initial_value=token)

Don’t use roboto secrets write for this token. It takes the value as a command-line argument, so other users on the same machine can see the token in the process list while the command runs.

Then delete gitlab-mcp-token.txt. The web app and the CLI never display the value again, but any member of your organization can read it through the SDK or API; see Security notes.

Step 7: Attach the token to the GitLab server#

  1. Go to Settings → AI → MCP Connections and find the GitLab card.

  2. In the Org connection row, click Add token, pick the secret you created, and save. The row’s status changes to Set.

Roboto decides which GitLab tools the AI may call, and enables only GitLab’s read-only tools. If the AI should create issues, merge requests, or comments, contact support@roboto.ai to enable GitLab’s write tools, and create the token with the read-write preset (Step 4). To see which tools GitLab offers, Roboto support may fetch GitLab’s tool list for your organization, using the org token.

If you haven’t already, add AI context to the GitLab card describing how your organization uses GitLab; see Add organization context.

To stop using the org token, click Remove in the Org connection row, then Remove token to confirm. The Roboto secret itself is kept. Service users and members without a working personal connection lose access to GitLab; members with a personal connection keep using it.

Verify the connection#

From a member account without a personal GitLab connection, in an agent a trigger launches, or by mentioning @Roboto in Slack, ask the AI something that needs GitLab:

  • “List the open merge requests in acme/firmware.”

  • “Is there a GitLab issue about this battery failsafe error?”

To check the token without Roboto, call GitLab’s MCP server directly:

GITLAB_HOST=gitlab.com
printf 'Token: ' >&2; IFS= read -rs TOKEN; echo >&2
# Read the header from a file descriptor so the token never appears in `ps` output.
curl -s -X POST "https://$GITLAB_HOST/api/v4/mcp" \
  -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -w '\nHTTP %{http_code}\n' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"1"}}}'

A working token prints HTTP 200 with "serverInfo":{"name":"Official GitLab MCP Server",...} in the response.

Troubleshooting#

  • 403 “MCP server not enabled for any of your groups” (GitLab.com) or 403 “MCP server disabled for this instance” (self-managed): MCP client access is off (Step 2), or, on GitLab.com, the service account isn’t a member of the top-level group yet. GitLab 19.4 and earlier return 404 Not Found for these cases instead. On GitLab.com, if both are now correct, GitLab may be serving a cached answer, since it caches this check for one hour per user. To clear the cache for all group members, turn MCP client access → Allow connection to GitLab off, save, turn it back on, and save again.

  • 403 Forbidden for every call: the token is probably a legacy token, which GitLab’s MCP server rejects. Create a fine-grained token with the script in Step 4.

  • 401 Unauthorized: the token expired or someone revoked it. Create a new token and update the secret (see Rotate the token).

  • The AI says it can’t access a project: the service account isn’t a member of that project, or the token’s permissions leave it out. Check the membership (Step 1), or create a new token that lists the project.

  • The AI doesn’t offer a GitLab tool you expected: the tool may be a write tool, which Roboto disables by default, or GitLab may have added it after Roboto last fetched GitLab’s tool list. Contact support@roboto.ai to enable the tool or refresh the list.

Security notes#

Membership and role limit what the AI can do. Until GitLab resolves issue 631630 (planned for GitLab 19.6), some MCP tools, including the one that creates issues, ignore a token’s fine-grained permissions and act with the service account’s full role. In Roboto’s testing, the MCP server created an issue in a project outside the token’s permissions, while GitLab’s REST API refused the same request. Until GitLab fixes the issue:

  • add the service account only to the groups and projects the AI should touch;

  • give it the lowest role that works (Reporter for read-only use);

  • treat anything the service account can reach as reachable by every member of your Roboto organization.

Any organization member can read or replace the token. A Roboto secret belongs to the whole organization: any member can read or change its value, or delete it, through the SDK or API. Only attaching the secret to the GitLab server as the org token, or removing it, requires an org admin. A member could therefore read the token and call GitLab’s API directly, outside the GitLab tools Roboto enables, or replace its value so that everyone using the org token acts as a different GitLab account.

A working personal connection takes precedence. Members who connected GitLab with the Connect button act as themselves, with their own GitLab permissions, while that connection works. If it stops working, Roboto uses the org token for them, and they act as the service account until they reconnect. In Slack, everyone acts as the service account.

Rotate the token#

Rotate the token before it expires, or right away if it may have leaked:

  1. Create a new fine-grained token (Steps 3 to 5).

  2. Update the value of the existing Roboto secret, in Settings → Secrets or with the SDK:

    from pathlib import Path
    
    from roboto.domain.secrets import Secret
    
    token = Path("gitlab-mcp-token.txt").read_text().strip()
    Secret.from_name("gitlab-mcp-token").update_value(token)
    

    Then delete gitlab-mcp-token.txt. You don’t need to change anything on the GitLab card.

  3. Revoke the old fine-grained token in GitLab.

A conversation still using the old token switches to the new one when GitLab rejects the old.