doc/development/duo_agent_platform/mcp/graphql_integration.md
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.
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:
Introduce a two-layer architecture for GraphQL-based MCP tools:
Layer 1: GraphQL Tool Classes (Mcp::Tools::Base::GraphqlTool)
.graphql files that are validated against the schema at build timeGitlabSchemaLayer 2: Service Wrappers (Mcp::Tools::*Service < Base::GraphqlService)
GraphqlService which provides user validation and GraphQL tool executionVersionable concernexecute_graphql_toolgraph 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
Claude/Cursor] -->|MCP Request| B[CreateWorkItemNoteService
Base::GraphqlService]
B -->|params, version| E[CreateWorkItemNoteTool
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
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
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.
| Layer | Pattern | Example |
|---|---|---|
| Service wrapper | <Operation>Service | Mcp::Tools::Labels::SearchService |
| GraphQL tool | <Operation>Tool | Mcp::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.
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:
.graphql files that are validated against the schema at build timeMcp::Tools::Base::Response objects (success or error).graphql filesStore 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')
}
[!note] 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
.graphqlfile. 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.
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:
register_versioncurrent_user is set before executionexecute_graphql_tool helper to instantiate and execute GraphQL toolsgraphql_tool_class and version-specific perform_X_Y_Z methodsFile: 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
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:
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.graphql_tool_class to specify which tool to useexecute_graphql_tool(arguments) which handles tool instantiation and executionFile: 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
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 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:
Example use cases:
Implemented issues:
Authorization Flow:
service.execute(request:, params:)GraphqlService.execute validates current_user presenceBaseService.execute calls perform(arguments)perform method calls execute_graphql_tool(arguments)authorize directive in mutations/resolversGraphQL Context:
current_user: Set on every GraphQL executionis_sessionless_user: false: Marks as API/MCP requestThree Error Levels:
current_userResponse.error(message)Response.error(joined_messages)Response.error(joined_messages)Error Propagation:
GitlabSchema.execute → GraphqlTool.process_result →
GraphqlService.execute_graphql_tool → Response.error → MCP Client
Multiple Mutation Error Handling:
createIssue succeeds but updateIssue fails:
Response.error(...)"updateIssue: Title can't be blank"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
ee/spec/services/ee/mcp/tools/manager_spec.rbspec/requests/api/mcp/handlers/list_tools_spec.rb and ee/spec/requests/api/mcp/handlers/list_tools_spec.rbApproach: 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:
Cons:
Decision: Rejected due to maintainability concerns.
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:
Cons:
Decision: Rejected due to security and authorization concerns.
Approach: Automatically generate MCP tools by introspecting GraphQL schema.
Pros:
Cons:
Decision: We will consider this in the next iteration. Manual tool creation provides better control and documentation.
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.