W1.1: Pass the access token into MCP GraphQL and custom tool execution

Part of the MCP granular token work (#630327, W1.1). Depends only on the phase 1 granular token foundation already on master (Authz::Boundary, Authz::Tokens::AuthorizeGranularScopesService). OAuth coverage follows automatically once !256787 (merged) adds Authz::GranularTokenInterface to OauthAccessToken; until then this change is a no-op for OAuth tokens.

Scope

Granular PATs can already call /api/v4/mcp (the entry point accepts the granular scope and requires execute_mcp_tool at the user boundary), but after entry the token is dropped. Mcp::Tools::Base::GraphqlService#set_cred and Mcp::Tools::Base::CustomService#set_cred discard it, and Mcp::Tools::Base::GraphqlTool#execution_context never includes access_token. Gitlab::Graphql::Authz::GranularScopeAuthorization#authorized? treats a missing token as authorized and CustomService#authorize! only runs Ability.allowed?, so GraphQL-backed and custom tools run with the user's full access regardless of the token's declared scopes.

REST re-dispatch tools (ApiTool#execute replays the Rack env, so the nested Grape route reruns authorize_granular_token_scopes!) and aggregated tools (which wrap only ApiTool instances) are already correct. The parent lists aggregated tools as needing the fix; they do not. A request spec locks this in.

Thread the token through: API::Mcp::Handlers::CallTool#invoke gains access_token: nil, and configure_tool_credentials passes access_token: to set_cred for CustomService and GraphqlService. Use the APIGuard access_token helper (the PAT/OAuth model), not oauth_access_token (a Doorkeeper wrapper). Leave ListTools alone, and do not touch ApiService (its access_token is a bearer string for outbound HTTP; it has no subclasses). On the GraphQL side, GraphqlService#set_cred stores the token and execute_graphql_tool forwards access_token: to graphql_tool_class.new; GraphqlTool#initialize accepts it and execution_context adds it. Do not add a scope_validator: with a nil validator ObjectAuthorization#scopes_ok? is vacuously true, while a real one would fail every legacy mcp-scoped token against the default [:api, :read_api] type scopes and null out all GraphQL tools. Track that gap separately.

For custom tools, CustomService#authorize! keeps its ability check and, when the token responds to granular?, runs AuthorizeGranularScopesService with granular_boundary(target) (a project or group as itself, otherwise target.project) and a new abstract granular_permissions method, so new custom tools deny granular tokens by default. On denial, record Current.add_granular_denied_permissions and raise CustomService::GranularAccessDeniedError; CustomService#execute already rescues into Response.error, so the denial surfaces as an isError tool result, the same way REST tools surface insufficient_granular_scope.

Gate everything behind a new mcp_granular_token_authorization feature flag (gitlab_com_derisk, actor current_user, default off), checked once in lib/api/mcp/base.rb. It stays off until the GraphQL directive coverage work (#631631) lands, because undirected types such as WorkItemType would otherwise return no data for granular-PAT users.

Permission names

The current auth_ability values are policy abilities, not assignable permissions, and AuthorizeGranularScopesService raises InvalidInputError on them. Use the REST route-setting names:

Tool granular_permissions Boundary
get_job read_job job.project
get_artifact_file download_job_artifact job.project
list_releases read_release project
list_tags read_repository_tag project
get_merge_request_conflicts read_merge_request mr.target_project
get_mcp_server_version none (keeps authorize! → true)

Run bundle exec rake gitlab:permissions:validate; add any permission reachable only through MCP to GRANULAR_TOKEN_NON_API_CONSUMERS in lib/tasks/gitlab/permissions/assignable/validate_task.rb.

Acceptance criteria

  • access_token is threaded from CallTool#invoke into GraphqlService and CustomService, gated by mcp_granular_token_authorization
  • GraphqlTool#execution_context includes access_token; no scope_validator is added
  • CustomService#authorize! runs AuthorizeGranularScopesService for granular tokens and surfaces denials as isError tool results
  • granular_permissions names are assignable permissions and gitlab:permissions:validate passes
  • New request spec: a granular PAT with only execute_mcp_tool is denied on a GraphQL, a custom, a REST, and an aggregated tool, then allowed after the matching project permission is added
  • Existing legacy mcp OAuth specs stay green with the flag on and off
Edited by Eugie Limpin