Help us learn about your current experience with the documentation. Take the survey.

MCP GraphQL Integration

This design introduces a reusable pattern for creating Model Context Protocol (MCP) tools that leverage the GitLab GraphQL API. The solution provides a two-layer architecture: a reusable GraphqlTool class that handles GraphQL execution and error processing, and service wrappers extending GraphqlService that handle validation and response formatting.

This pattern enables developers to create new GraphQL-powered MCP tools with minimal boilerplate while maintaining consistent security and error handling practices.

The implementation allows AI clients (like Duo agent platform, Claude, Cursor) to perform complex operations on GitLab resources through GraphQL mutations and queries, while reusing existing GraphQL schema definitions and authorization logic.

Motivation

Current State

The GitLab MCP implementation uses a route-based architecture with route_setting :mcp that exposes REST API endpoints as MCP tools. While effective for REST operations, this approach has limitations for GraphQL integration:

  • No built-in pattern for GraphQL-based MCP tools
  • Developers must manually wire GraphQL execution
  • Inconsistent error handling between REST and GraphQL tools
  • No reusable abstraction for common GraphQL operations
  • No support for atomic multi-operation requests

Goals

  1. Reusability: Create a base class that eliminates boilerplate for GraphQL-based MCP tools
  2. Consistency: Maintain uniform error handling, and response formatting across all GraphQL tools
  3. Developer Experience: Enable creation of new GraphQL MCP tools in under 50 lines of code
  4. Security: Leverage existing GraphQL authorization
  5. Type Safety: Use static GraphQL schema definitions to prevent runtime errors
  6. Maintainability: Keep GraphQL operations separate from business logic for easier testing
  7. Atomicity: Support multiple mutations in a single request

Non-Goals

  • Replacing the existing route-based MCP architecture
  • Creating a GraphQL proxy or query builder
  • Implementing automatic schema introspection for tool generation
  • Supporting GraphQL subscriptions (real-time updates)
  • Creating a generic GraphQL client library
  • Executing mutations in parallel (GraphQL executes mutations sequentially)

Solution

Introduce a two-layer architecture for GraphQL-based MCP tools:

Layer 1: GraphQL Tool Classes (Mcp::Tools::Base::GraphqlTool)

  • Load GraphQL operations (queries and mutations) from .graphql files that are validated against the schema at build time
  • Support single mutation or multiple mutations in one request
  • Transform input parameters to GraphQL input format
  • Execute operations against GitlabSchema
  • Standardize result processing and error handling

Layer 2: Service Wrappers (Mcp::Tools::*Service < Base::GraphqlService)

  • Extend GraphqlService which provides user validation and GraphQL tool execution
  • Implement version-specific logic using Versionable concern
  • Define tool metadata (description, input schema) per version
  • Call Layer 1 GraphQL tools via execute_graphql_tool
  • Format responses for MCP protocol
  • Handle service-level errors and logging

Architecture Diagram

graph TB
    accTitle: MCP GraphQL two-layer architecture
    accDescr: AI client sends an MCP request to CreateWorkItemNoteService, which delegates to CreateWorkItemNoteTool, which executes a GraphQL mutation against GitlabSchema and returns the result back up the chain.
    A[AI Client<br/>Claude/Cursor] -->|MCP Request| B[CreateWorkItemNoteService<br/>Base::GraphqlService]
    B -->|params, version| E[CreateWorkItemNoteTool<br/>Base::GraphqlTool]
    E -->|GraphQL Mutation| F[GitlabSchema.execute]
    F -->|Response| E
    E -->|Structured Result| B
    B -->|MCP Response| A

    style B fill:#e1f5fe
    style E fill:#fff9c4
    style F fill:#f3e5f5

Data Flow

1. MCP Client Request
2. Service.execute(params)
   ├─ Validate current_user exists
   ├─ Call super (BaseService.execute)
   │  ├─ Validate arguments against input_schema
   │  └─ Call perform(arguments)
3. perform method calls execute_graphql_tool(arguments)
   ├─ Build GraphQL query/mutation with static schema
   ├─ Transform params → GraphQL input variables
   ├─ Execute GitlabSchema.execute(graphql_operation, variables)
   └─ Process result (success/errors)
4. Format response
   ├─ Success: Response.success(message, payload)
   └─ Error: Response.error(message)
5. Return to MCP Client

Design and Implementation Details

Naming conventions

Name service and tool subclasses after the operation, without a Graphql prefix. Only the base classes GraphqlService and GraphqlTool keep the prefix. Every subclass already inherits from one of them, and there is no separate graphql folder, so the prefix adds no information.

LayerPatternExample
Service wrapper<Operation>ServiceMcp::Tools::Labels::SearchService
GraphQL tool<Operation>ToolMcp::Tools::WorkItems::CreateWorkItemNoteTool

The tool_name keys registered in Mcp::Tools::Manager are a public, append-only contract. Renaming a Ruby class does not rename its registered tool, so keep the keys unchanged.

Layer 1: GraphQL Tool Base Class

File: app/services/mcp/tools/base/graphql_tool.rb

Purpose: Base class for GraphQL-based MCP tools that handles GraphQL execution, error processing, and versioning.

module Mcp
  module Tools
    module Base
      class GraphqlTool
        include Mcp::Tools::Concerns::Versionable

        QUERIES_ROOT = Rails.root.join('app/graphql/queries/mcp').freeze

        def self.load_graphql(relative_path)
          File.read(QUERIES_ROOT.join(relative_path)).freeze
        end

        attr_reader :current_user, :params

        def initialize(current_user:, params:, version: nil)
          @current_user = current_user
          @params = params
          initialize_version(version)
        end

        # Override in subclasses or use version metadata
        def graphql_operation
          raise NotImplementedError unless self.class.version_metadata(version)[:graphql_operation]

          self.class.version_metadata(version)[:graphql_operation]
        end

        def operation_name
          self.class.version_metadata(version)[:operation_name] ||
          raise(NotImplementedError, "operation_name must be defined")
        end

        # Can be overridden with version-specific methods
        def build_variables
          raise NotImplementedError, "build_variables must be implemented"
        end

        def execute
          result = GitlabSchema.execute(
            graphql_operation_for_version,
            variables: build_variables_for_version,
            context: execution_context
          )

          process_result(result)
        end

        private

        def execution_context
          {
            current_user: current_user,
            is_sessionless_user: false
          }
        end

        def process_result(result)
          if result['errors']
            error_messages = extract_error_messages(result['errors'])
            return ::Mcp::Tools::Base::Response.error(error_messages.join(', '))
          end

          operation_data = result.dig('data', operation_name)

          return ::Mcp::Tools::Base::Response.error("Operation returned no data") if operation_data.nil?

          operation_errors = operation_data['errors']
          if operation_errors&.any?
            error_messages = extract_error_messages(operation_errors)
            return ::Mcp::Tools::Base::Response.error(error_messages.join(', '))
          end

          formatted_content = [{ type: 'text', text: Gitlab::Json.dump(operation_data) }]
          ::Mcp::Tools::Base::Response.success(formatted_content, operation_data)
        end

        def extract_error_messages(errors)
          errors.map do |error|
            if error.is_a?(String)
              error
            elsif error.is_a?(Hash)
              error['message'] || error.to_s
            else
              error.to_s
            end
          end
        end
      end
    end
  end
end

Key Design Decisions:

  • File-backed operations: GraphQL operations live in .graphql files that are validated against the schema at build time
  • MCP Response format: Returns Mcp::Tools::Base::Response objects (success or error)
  • Flexible operation support: Supports both mutations and queries
  • Context isolation: Each tool instance is independent (no shared state)
  • Framework agnostic: Works with any GraphQL operation type
  • Error extraction: Handles both GraphQL-level and operation-level errors

Store GraphQL operations in .graphql files

Store each tool’s GraphQL operation in a .graphql file under app/graphql/queries/mcp/, and load it with GraphqlTool.load_graphql. Do not embed the operation as an inline string or HEREDOC.

Files in this directory are validated against GitlabSchema at build time by spec/graphql/all_queries_spec.rb, so an operation that drifts from the schema fails CI. An inline operation skips this check.

Place the file under a subdirectory that mirrors the tool’s domain, and name it with a .query.graphql or .mutation.graphql suffix:

app/graphql/queries/mcp/
  work_items/create_note.mutation.graphql
  work_items/get_work_item_types.query.graphql
  labels/search.query.graphql

Use a verb-first name for a query operation to match the operations already in app/graphql/queries, for example getWorkItemTypes rather than WorkItemTypes. Mutations already use a verb-first name, such as createNote.

Start each file with a # @feature_category: comment. The frontend graphql_require_feature_category lint rule requires one on every GraphQL operation, and the lint job fails without it:

# @feature_category: mcp_server
query getWorkItemTypes($fullPath: ID!) {
  # ...
}

Load the file in register_version with the direct load_graphql(...) form, not a lambda. graphql_operation_for_version calls a lambda on every request, so -> { load_graphql(...) } rereads the file each time:

register_version VERSIONS[:v0_1_0], {
  operation_name: 'createNote',
  graphql_operation: load_graphql('work_items/create_note.mutation.graphql')
}

This works only for static operations. An operation composed at load time (for example, a query built from EE-overridden fragments) cannot live in a flat .graphql file. Build it through a method and reference that method with a lambda (graphql_operation: -> { build_query }) so it is composed per request.

The Mcp/UseGraphqlQueryFile RuboCop rule flags an inline string or HEREDOC passed as graphql_operation: and points to load_graphql.

Layer 2: GraphqlService Base Class

File: app/services/mcp/tools/base/graphql_service.rb

Purpose: Provides a specialized base service for GraphQL-based MCP tools with user validation, versioning support, and GraphQL tool execution.

module Mcp
  module Tools
    module Base
      class GraphqlService < BaseService
        include Mcp::Tools::Concerns::Versionable
        extend Gitlab::Utils::Override

        def initialize(name:, version: nil)
          super(name: name)
          initialize_version(version)
        end

        override :set_cred
        def set_cred(current_user: nil, access_token: nil)
          @current_user = current_user
          _ = access_token # access_token is not used in GraphqlService
        end

        override :execute
        def execute(request: nil, params: nil)
          return Response.error("#{self.class.name}: current_user is not set") unless current_user.present?

          super
        end

        protected

        # Subclasses should override this to return their GraphQL tool class
        def graphql_tool_class
          raise NotImplementedError, "#{self.class.name}#graphql_tool_class must be implemented"
        end

        # Default implementation - can be overridden in subclasses
        def perform_default(_arguments = {})
            raise NoMethodError, "No implementation found for version #{version}"
        end

        private

        def execute_graphql_tool(arguments)
          tool = graphql_tool_class.new(
            current_user: current_user,
            params: arguments,
            version: version
          )

          tool.execute
        end
      end
    end
  end
end

Key Features:

  • Extends BaseService: Inherits MCP protocol support and validation
  • Versionable: Supports multiple versions with register_version
  • User validation: Ensures current_user is set before execution
  • GraphQL tool integration: Provides execute_graphql_tool helper to instantiate and execute GraphQL tools
  • Template pattern: Subclasses define graphql_tool_class and version-specific perform_X_Y_Z methods

Versionable Concern Enhancements

File: app/services/mcp/tools/concerns/versionable.rb

GraphQL-Specific Methods:

# Retrieve GraphQL operation from version metadata
def graphql_operation
  version_metadata.fetch(:graphql_operation) do
    raise NotImplementedError, "GraphQL operation not defined for version #{version}"
  end
end

# Retrieve operation name from version metadata
def operation_name
  version_metadata.fetch(:operation_name) do
    raise NotImplementedError, "operation_name must be defined"
  end
end

protected

# Get operation with fallback to method override
def graphql_operation_for_version
  version_metadata[:graphql_operation] || graphql_operation
end

# Call version-specific build_variables method or fallback
def build_variables_for_version
  method_name = "build_variables_#{version_method_suffix}"
  respond_to?(method_name, true) ? send(method_name) : build_variables
end

Version-Specific Variable Building:

Tools can define version-specific variable building methods:

# Default implementation
def build_variables
  { input: { projectPath: params[:project_path] } }
end

# Version 2.0.0 specific implementation
def build_variables_v2_0_0
  {
    input: {
      projectPath: params[:project_path],
      includeArchived: params[:include_archived]
    }.compact
  }
end

Service Wrapper Pattern

File: app/services/mcp/tools/work_items/create_work_item_note_service.rb

Purpose: Provides input validation, MCP protocol compliance, and version management. Authorization is delegated to GraphQL layer.

This input_schema below omits optional properties such as minLength and enum; add them as needed.

module Mcp
  module Tools
    module WorkItems
      class CreateWorkItemNoteService < Base::GraphqlService
        register_version '0.1.0', {
          description: 'Create a new note (comment) on a GitLab work item',
          annotations: {
            readOnlyHint: false,
            destructiveHint: false
          },
          input_schema: {
            type: 'object',
            properties: {
              url: {
                type: 'string',
                description: 'GitLab URL for the work item.'
              },
              project_id: {
                type: 'string',
                description: 'ID or path of the project. Required if URL and group_id are not provided.'
              },
              work_item_iid: {
                type: 'integer',
                description: 'Internal ID of the work item. Required if URL is not provided.'
              },
              body: {
                type: 'string',
                description: 'Content of the note/comment (max 1,048,576 characters)',
                maxLength: 1_048_576
              }
            },
            required: ['body']
          }
        }

        protected

        # Specify which GraphQL tool class to use
        def graphql_tool_class
          Mcp::Tools::WorkItems::CreateWorkItemNoteTool
        end

        # Version 0.1.0 implementation
        def perform_v0_1_0(arguments)
          execute_graphql_tool(arguments)
        end

        # Fallback to 0.1.0 behavior for any unimplemented versions
        override :perform_default
        def perform_default(arguments = {})
          perform_v0_1_0(arguments)
        end
      end
    end
  end
end

Key Design Decisions:

  • Inheritance from GraphqlService: Provides user validation and GraphQL tool execution infrastructure
  • Version registration: Uses register_version to define tool metadata per version
  • Automatic additionalProperties: false: The shared tool abstraction rejects unrecognized arguments by default, so you do not add additionalProperties to input_schema. To accept arbitrary arguments, set additionalProperties: true. Schemas that use oneOf, anyOf, allOf, or $ref keep their own behavior.
  • Version-specific methods: Implement perform_v0_1_0, perform_v0_2_0, etc. for different versions
  • GraphQL tool class: Override graphql_tool_class to specify which tool to use
  • Simplified perform methods: Just call execute_graphql_tool(arguments) which handles tool instantiation and execution
  • Response handling: Tool execution returns MCP Response directly

Mutation Tool Example

File: app/services/mcp/tools/work_items/create_work_item_note_tool.rb

Use Case: Create a note (comment) on a work item.

Each version loads its operation from a .graphql file. For more information, see Store GraphQL operations in .graphql files.

# app/graphql/queries/mcp/work_items/create_note.mutation.graphql
# @feature_category: mcp_server
mutation createNote($input: CreateNoteInput!) {
  createNote(input: $input) {
    note {
      id
      body
      internal
      createdAt
      updatedAt
      author {
        id
        name
        username
        avatarUrl
        webUrl
      }
      discussion {
        id
      }
    }
    errors
  }
}

WorkItems::BaseTool inherits from Base::GraphqlTool and resolves the target work item from either a URL or a project and internal ID pair:

module Mcp
  module Tools
    module WorkItems
      class CreateWorkItemNoteTool < BaseTool
        register_version VERSIONS[:v0_1_0], {
          operation_name: 'createNote',
          graphql_operation: load_graphql('work_items/create_note.mutation.graphql')
        }

        def build_variables
          validate_no_quick_actions!(params[:body], field_name: 'note body')

          work_item_id = resolve_work_item_id

          { input: build_note_input(work_item_id) }
        end

        private

        def build_note_input(work_item_id)
          {
            noteableId: work_item_id,
            body: params[:body],
            internal: params[:internal],
            discussionId: params[:discussion_id]
          }.compact
        end
      end
    end
  end
end

Register a later version

A tool with a single version loads an unversioned file, such as work_items/create_note.mutation.graphql. When you add a second version, rename that file to include its version and add a file for the new version, so every registered version maps to its own file:

app/graphql/queries/mcp/work_items/
  create_note.v0_1_0.mutation.graphql
  create_note.v0_2_0.mutation.graphql

Register each version against its own file:

register_version VERSIONS[:v0_1_0], {
  operation_name: 'createNote',
  graphql_operation: load_graphql('work_items/create_note.v0_1_0.mutation.graphql')
}

# A later version returns more fields from its own file
register_version '0.2.0', {
  operation_name: 'createNote',
  graphql_operation: load_graphql('work_items/create_note.v0_2_0.mutation.graphql')
}

To send different variables for a version, define a build_variables_v<version> method, such as build_variables_v0_2_0 for version 0.2.0. The suffix is the version with each dot replaced by an underscore. build_variables_for_version calls that method when it exists, and otherwise falls back to build_variables. Version-specific perform_v<version> methods follow the same pattern.

Composite Tool Example

Composite tools combine multiple related operations into a single, cohesive MCP tool. Instead of creating separate tools for each different resource, a composite tool provides a unified interface with operation-specific parameters.

Important Limitation: You can only perform one mutation operation per tool invocation.

Benefits of composite tools:

  • Reduced tool proliferation: Fewer tools for AI clients to discover and manage
  • Consistent interface: Related operations share common parameters
  • Better discoverability: Logical grouping makes it easier for users to find related functionality
  • Simplified maintenance: Shared validation and error handling logic

Example use cases:

  • Create work item notes (mutation) - A single tool that handles creating notes (comments) on work items, with flexible identification methods (URL, project/group ID + IID)
  • Get work item notes (query) - A single tool that handles retrieving notes (comments) on work items, with flexible identification methods (URL, project/group ID + IID)

Implemented issues:

  • #581890 - GraphQL-based MCP mutation tool for creating notes (comments) on work items
  • #581892 - GraphQL-based MCP query tool for retrieving notes (comments) from work items

Security Model

Authorization Flow:

  1. MCP client calls service.execute(request:, params:)
  2. GraphqlService.execute validates current_user presence
  3. BaseService.execute calls perform(arguments)
  4. perform method calls execute_graphql_tool(arguments)
  5. GraphQL tool executes mutation/query
  6. GraphQL enforces authorization via authorize directive in mutations/resolvers

GraphQL Context:

  • current_user: Set on every GraphQL execution
  • is_sessionless_user: false: Marks as API/MCP request
  • Context is consistent across all mutations in a multi-mutation request

Error Handling Strategy

Three Error Levels:

  1. Service-level errors (GraphqlService):
    • Missing current_user
    • Returns: Response.error(message)
  2. GraphQL-level errors (syntax, validation):
    • Invalid GraphQL syntax
    • Type mismatches
    • Returns: Response.error(joined_messages)
  3. Mutation-level errors (business logic):
    • Validation failures (e.g., empty title)
    • State conflicts
    • Returns: Response.error(joined_messages)

Error Propagation:

GitlabSchema.execute → GraphqlTool.process_result →
GraphqlService.execute_graphql_tool → Response.error → MCP Client

Multiple Mutation Error Handling:

  • Failure: If any mutation fails, the entire operation is marked as failed
  • Error aggregation: Errors from all mutations are collected and returned together
  • Example: If createIssue succeeds but updateIssue fails:
    • Overall result: Response.error(...)
    • Errors include: "updateIssue: Title can't be blank"

Creating New Tools - Step-by-Step Guide

Step 1: Add the GraphQL operation file

Add the operation under app/graphql/queries/mcp/, in a subdirectory that mirrors the tool’s domain. For more information, see Store GraphQL operations in .graphql files.

# app/graphql/queries/mcp/your_domain/your.mutation.graphql
# @feature_category: mcp_server
mutation yourMutation($input: YourInput!) {
  yourMutation(input: $input) {
    result {
      id
      title
    }
    errors
  }
}

Step 2: Define the GraphQL tool class

# app/services/mcp/tools/your_domain/your_tool.rb
module Mcp
  module Tools
    module YourDomain
      class YourTool < Base::GraphqlTool
        # Load the operation from its .graphql file
        register_version '0.1.0', {
          operation_name: 'yourMutation',
          graphql_operation: load_graphql('your_domain/your.mutation.graphql')
        }

        # Implement variable building
        def build_variables
          {
            input: {
              projectPath: params[:project_path],
              title: params[:title]
            }.compact
          }
        end

        # Optional: Version-specific variable building
        private

        def build_variables_v0_2_0
          {
            input: {
              projectPath: params[:project_path],
              title: params[:title],
              extraField: params[:extra_field]
            }.compact
          }
        end
      end
    end
  end
end

Step 3: Create the service wrapper

# app/services/mcp/tools/your_domain/your_service.rb
module Mcp
  module Tools
    module YourDomain
      class YourService < Base::GraphqlService
        # Register version with metadata
        register_version '0.1.0', {
          description: 'Description of what this tool does',
          input_schema: {
            type: 'object',
            properties: {
              project_path: { type: 'string', description: '...' },
              title: { type: 'string', description: '...' }
            },
            required: ['project_path', 'title']
          }
        }

        protected

        # Specify the GraphQL tool class to use
        def graphql_tool_class
          Mcp::Tools::YourDomain::YourTool
        end

        # Version 0.1.0 implementation
        def perform_v0_1_0(arguments)
          execute_graphql_tool(arguments)
        end

        # Fallback to 0.1.0 behavior for any unimplemented versions
        override :perform_default
        def perform_default(arguments = {})
          perform_v0_1_0(arguments)
        end
      end
    end
  end
end

Step 4: Register the tool in the manager

GraphQL tools are registered separately from custom tools in Mcp::Tools::Manager:

GRAPHQL_TOOLS = {
  'your_tool_name' => ::Mcp::Tools::YourDomain::YourService
}.freeze

Step 5: Add tests

  • Unit tests for GraphQL tool
  • Integration tests for service
  • Update test in ee/spec/services/ee/mcp/tools/manager_spec.rb
  • Update tests in spec/requests/api/mcp/handlers/list_tools_spec.rb and ee/spec/requests/api/mcp/handlers/list_tools_spec.rb

Alternative Solutions

Alternative 1: Direct GraphQL Execution in Services

Approach: Execute GraphQL directly in service classes without abstraction layer.

class CreateWorkItemNoteService < Base::GraphqlService
  def perform_v0_1_0(params)
    result = GitlabSchema.execute(MUTATION, variables: params, context: {...})
    # Handle result inline
  end
end

Pros:

  • Fewer abstraction layers
  • All logic in one file

Cons:

  • Code duplication: Error handling repeated in every service
  • No reusability: GraphQL operations can’t be used elsewhere
  • Testing complexity: Must test GraphQL execution in every service test
  • Inconsistent patterns: Each developer implements differently

Decision: Rejected due to maintainability concerns.

Alternative 2: GraphQL Proxy Service

Approach: Generic service that accepts arbitrary GraphQL queries from MCP clients.

class ProxyService < Base::GraphqlService
  def perform_v0_1_0(params)
    query = params[:query]
    GitlabSchema.execute(query, variables: params[:variables], context: {...})
  end
end

Pros:

  • Maximum flexibility for clients
  • No need to create new tools

Cons:

  • Security risk: Clients could execute unauthorized queries
  • No schema validation: Can’t enforce required fields
  • Performance concerns: No query complexity limits
  • No discoverability: MCP clients can’t know what operations are available

Decision: Rejected due to security and authorization concerns.

Alternative 3: Auto-Generated Tools from GraphQL Schema

Approach: Automatically generate MCP tools by introspecting GraphQL schema.

Pros:

  • No manual tool creation
  • Always in sync with schema

Cons:

  • Generic descriptions: Auto-generated docs less useful than hand-written
  • Over-exposure: Would expose all mutations, not just approved ones
  • Parameter complexity: Hard to simplify complex nested inputs
  • Maintenance burden: Schema changes break all tools simultaneously

Decision: We will consider this in the next iteration. Manual tool creation provides better control and documentation.

Performance Considerations

Query Complexity: GraphQL tools inherit GitLab query complexity limits (200 by default).

Caching: GraphQL resolver-level caching applies automatically.

Batch Loading: GraphQL’s built-in batch loading prevents N+1 queries for nested fields.

Monitoring: All GraphQL executions logged via existing GraphqlLogger.

Success Metrics

  • Adoption: Number of GraphQL-based MCP tools created
  • Developer velocity: Time to create new tool
  • Code reuse: Lines of code saved vs. alternative approaches

Future Enhancements

  • Multiple Mutations: Support multiple mutation GraphQL calls. This can be implemented by calling different tools from the service class and aggregating the responses
  • Dependent mutations: Execute multiple GraphQL calls to support dependent mutations
  • Auto-Generated Tools: Automatically generate MCP tools by introspecting GraphQL schema