doc/development/duo_agent_platform/mcp/_index.md
This page includes information about developing and working with the GitLab MCP server.
To set up your development environment:
node and install mcp-remote globally. GDK comes with Node.js but installed AI assistants cannot use the GDK version.Add --debug to the mcp-remote command for more detailed logging. View MCP server logs by opening the Output and selecting
MCP:SERVERNAME. For the example below, it would be MCP:user-GitLab-GDK
{
"mcpServers": {
"GitLab-GDK": {
"command": "npx",
"args": [
"mcp-remote",
"https://gdk.test:3443/api/v4/mcp",
"--debug"
],
"env": {
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
}
}
}
}
Claude Desktop uses an unsupported version of Node.js. Create a custom wrapper script that uses a specific version:
#!/bin/bash
# Force use of your Node.js version
NODE_BIN="/PATH_TO_NODE_INSTALL/node/22.17.0/bin/node"
MCP_REMOTE_BIN="/PATH_TO_NODE_INSTALL/node/22.17.0/bin/mcp-remote"
# Run mcp-remote with your Node.js
exec "$NODE_BIN" "$MCP_REMOTE_BIN" "$@"
Use the wrapper script in the Claude Desktop configuration.
{
"mcpServers": {
"GitLab-GDK": {
"command": "/PATH_TO_REMOTE_WRAPPER_SCRIPT/mcp-remote-wrapper",
"args": [
"https://gdk.test:3443/api/v4/mcp",
"--debug"
],
"env": {
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
}
}
}
}
mcp-remoteTest the authentication from mcp-remote to GDK outside an AI assistant:
NODE_TLS_REJECT_UNAUTHORIZED=0 npx mcp-remote https://gdk.test:3443/api/v4/mcp --debug
If you switch branches, you may experience authentication issues which can include UNABLE_TO_VERIFY_LEAF_SIGNATURE
errors in the logs. The untrusted certificate error in the chain is specific to GDK instances that use TLS. The error is
caused by the https client used in Node.js and the mcp-remote library via npx. The https client doesn't trust
certificates signed outside a bundled certificate authorities list.
If you're encountering authentication issues, clearing your ~/.mcp-auth directory, as a last resort, resets stored
credentials for mcp-remote. When the AI Assistant reconnects to the MCP server, a browser window opens to prompt
for authorization.
rm -rf ~/.mcp-auth
MCP Inspector is an interactive developer tool for testing and debugging MCP servers.
The following command opens an intuitive Web UI for connecting to a server and listing and executing MCP tools:
npx -y @modelcontextprotocol/inspector npx
Our current development guidelines remain in early development. As we continue establishing tool development standards - especially for custom and
aggregated tools - we've created an interim mcp-tool-review-board committee to evaluate proposed tools before implementation and guide teams planning new MCP tools.
To add a new tool, please create a MCP Tool Proposal issue and follow the template instructions.
[!note] Tool implementation location depends on GitLab resource interaction:
- Tools that interact with GitLab resources should eventually live in MCP Server, but can be implemented in the Agent Platform for short-term or urgent needs.
- Tools that don't interact with GitLab resources should be implemented in the Agent Platform.
We are working to integrate MCP server functionality into the Agent Platform. You can track progress via this issue.
We strongly encourage all engineers to follow the tool proposal process and provide clear explanations of their use cases.
When adding or consolidating tools, follow these conventions to keep the tool surface small and predictable. These conventions reduce tool count and token overhead without losing functionality.
Verb classes: every tool name uses a verb_object shape, and the verb signals the operation
class:
get_ for a single object, list_ for a collection.save_ for create and update field mutations. The presence of id defines whether the operation is a create or update action.
Parameters required on create should be marked as such in the tool definition.
Intentional exception: a save_ tool may also fold non-field-mutation lifecycle actions (for
example retry, cancel) behind an action parameter, when those actions operate on the same
resource the tool creates and don't warrant a dedicated tool of their own. Route on the presence
of the resource's own ID rather than the parent identifier. For example, save_pipeline
treats an absent pipeline_id as create, and a present pipeline_id plus action as a lifecycle
transition on that pipeline. Document this in the tool description and record it as an
intentional exception (see below).delete_ for actual delete operations. These should never be folded into save_ to allow for better governance handling.add_ or other deviations from the pattern are reserved for objects that do not have a typical CRUD shape, such as commits, branches, or sessions (for example add_commit, add_branch).Resource identification: every project-scoped tool identifies its target the same way.
Each tool declares url, project_id, and the resource's internal ID in its own input schema.
To resolve those parameters, include the Mcp::Tools::Concerns::UrlParser and
Mcp::Tools::Concerns::ResourceFinder concerns.
A caller provides either:
url - a full GitLab URL that encodes the whole path (for example
https://gitlab.com/group/project/-/merge_requests/1), orproject_id (numeric ID or URL-encoded path such as gitlab-org%2Fgitlab) plus
the resource's internal ID (merge_request_iid, work_item_iid, commit_sha, and so on).Keep the project identifier and the internal ID as separate parameters.
Do not fold them into a
single id. They are different values (iid/sha is scoped to a project and is meaningless without
project_id), and url is already the single-value convenience that collapses them. When both url
and IDs are supplied they are cross-validated and a mismatch raises an error. Work-item tools accept
group_id or project_id in the same group.
Optional parameters: Base::BaseService treats an explicit null or "" value for an
optional parameter the same as an omitted key, so a caller that fills in every schema property
still passes validation for an optional enum parameter.
Reads (single vs. collection):
get_ tool through an include
parameter rather than separate list_* tools (for example get_merge_request with
include: ["diffs"]). Valid values are diffs, commits, notes, pipelines, and
discussions. Declare include as an array of enum values even when only one facet per call is
supported, and bound it with maxItems. Raising that cap later is additive, whereas changing the
parameter from a string to an array breaks existing callers. See get_pipeline
(include: ["jobs"]/["downstream_pipelines"]/["bridge_jobs"]) for a worked example of this
pattern, including a filter (job_status) scoped to a single facet.list_ tool (for example
list_merge_requests, list_pipelines).get_ reader and applies only to the relevant include
value. Document this in the parameter description.detail enum (none/stats/full_patch) on diff-bearing reads where the diff dominates
the payload (for example the diff facet of get_commit and the diffs facet of
get_merge_request). Do not retrofit detail where a better-suited knob already exists: file
content uses line pagination (offset/limit) and job logs use byte pagination
(byte_offset/byte_limit), because those APIs have no diff-style verbosity levels.job_status: failed filter instead of a separate failing_jobs facet).Pagination: a tool's pagination scheme mirrors the endpoint it wraps, rather than forcing a single server-wide convention. Do not translate between schemes (for example, do not wrap a page number in an opaque cursor): a synthetic cursor over offset pagination hides the mechanism without gaining cursor stability, and misleads the caller about the guarantees the endpoint provides.
page (1-based, default 1) and per_page
(default 20, capped at 100), and return a metadata object with page, per_page, and
has_more. Applies to tools such as list_repository_tree, list_branches, list_commits,
list_pipelines, and search.first (default 20, capped at 100)
and after (an opaque cursor), and return a pageInfo object with endCursor and hasNextPage.
Applies to tools such as list_work_items and list_merge_requests.offset/limit) and job logs use byte pagination
(byte_offset/byte_limit). Return a system_instruction telling the caller how to fetch the
next window.get_ reader is prefixed with the facet name (for example
notes_page/notes_per_page on get_merge_request, comments_page/comments_per_page on
get_commit) and follows the scheme of the endpoint backing that facet.Consolidation over proliferation:
search tool with a scope parameter instead of
adding per-resource search tools.Document intentional exceptions. When a tool deliberately breaks a convention (a one-off action
verb, a second write_ tool on one resource), record it as intentional
in the proposal so it is not mistaken for an oversight.
This merge request defines a process for creating an MCP tool from an API route.
Adding the following route_setting to an API route definition:
route_setting :mcp, tool_name: :get_issue, params: [:id, :issue_iid], resource_name: "issue"
get_issue tool to the list of tools and enables its executionparams argument. For example, only id and issue_iid are advertised and acceptedresource_name field provides a resource-specific 404 error message (for example, "404 Issue Not Found" instead of a generic "404 Not Found"). Use a lowercase string such as "issue" or "merge request". The first letter is capitalized in the rendered message.This merge request provides more examples.
Aggregated API tools combine multiple related API tools into a single unified interface, reducing tool count and improving the user experience. The search tool demonstrates this pattern by consolidating global, group, and project search into one tool.
When to use aggregated tools:
Use aggregated tools when you have multiple API endpoints that serve similar purposes but operate at different scopes (global, group, project). This reduces cognitive load on the LLM by presenting one tool instead of three.
Implementation steps:
Mcp::Tools::Base::AggregatedService:module Mcp
module Tools
class ExampleAggregatedService < Base::AggregatedService
include Gitlab::Utils::StrongMemoize
extend ::Gitlab::Utils::Override
register_version '0.1.0', {
description: 'My example aggregated tool',
input_schema: {
type: 'object',
properties: {},
required: []
}
}
override :tool_name
def self.tool_name
'new_tool'
end
override :select_tool
def select_tool(args)
tool_name = if args[:group_id]
:example_tool_for_group
elsif args[:project_id]
:example_tool_for_project
end
tools.find { |tool| tool.name.to_sym == tool_name }
end
override :transform_arguments
def transform_arguments(args)
if args[:group_id]
args.merge(id: args[:group_id])
elsif args[:project_id]
args.merge(id: args[:project_id])
else
args
end
end
end
end
end
route_setting :mcp, tool_name: :example_tool_for_group, params: [:id], aggregators: [::Mcp::Tools::ExampleAggregatedService]
route_setting :mcp, tool_name: :example_tool_for_project, params: [:id], aggregators: [::Mcp::Tools::ExampleAggregatedService]
Mcp::Tools::Manager automatically discovers aggregated tools by scanning routes with
aggregators specified and instantiates the aggregator class with the collected tools.For MCP tools that use the GitLab GraphQL API, see the GraphQL integration guidelines.
To scaffold a GraphQL-backed tool with an AI coding assistant, use the
gitlab-mcp-tool-builder skill.
The skill distills the guidelines on this page and in the GraphQL integration guidelines,
adds the common gotchas, and walks you through the tool class, service class,
.graphql operation file, manager registration, and specs.
This repository includes the skill at .claude/skills/gitlab-mcp-tool-builder/, so
you do not install anything:
AGENTS.md convention load the same skill through the
.agents/skills symlink.The guidelines are the source of truth. If the skill and the guidelines disagree, follow the guidelines and update the skill.
For tools with distinct functionality that should remain separate from API exposure, you can define a standalone class (see this example for reference).
[!warning] More tools aren't always better. The research shows that both context size and tool count have diminishing returns and eventually lead to performance degradation. Consider tool consolidation, specialized sub-agents, or dynamic tool routing instead of continuously expanding your toolset.
MCP tools use semantic versioning to avoid breaking changes for consumers. When modifying a tool, use the versioning system introduced in this merge request.
Why versioning matters:
LLMs and AI agents cache tool schemas and build workflows around specific tool behaviors. Changes to tool parameters, descriptions, or output formats can break existing integrations. Versioning allows safe evolution while maintaining backward compatibility.
Version registration pattern:
For aggregated API, custom, and GraphQL tools, register versions using register_version:
module Mcp
module Tools
class GetServerVersionService < Base::CustomService
register_version '0.1.0', {
description: 'Get the current version of MCP server.',
input_schema: {
type: 'object',
properties: {},
required: []
}
}
def perform_v0_1_0(_arguments = {})
data = { version: Gitlab::VERSION, revision: Gitlab.revision }
formatted_content = [{ type: 'text', text: data[:version] }]
::Mcp::Tools::Base::Response.success(formatted_content, data)
end
override :perform_default
def perform_default(arguments = {})
perform_v0_1_0(arguments)
end
end
end
end
Adding a new version:
When you need to modify a tool's behavior:
register_version '0.2.0', {
description: 'Get version with additional metadata.',
input_schema: {
type: 'object',
properties: {
include_metadata: {
type: 'boolean',
description: 'Include additional metadata'
}
},
required: []
}
}
def perform_v0_2_0(arguments = {})
data = {
version: Gitlab::VERSION,
revision: Gitlab.revision
}
if arguments[:include_metadata]
data[:metadata] = { build_date: Time.current }
end
formatted_content = [{ type: 'text', text: data[:version] }]
::Mcp::Tools::Base::Response.success(formatted_content, data)
end
perform_default to use the latest version:override :perform_default
def perform_default(arguments = {})
perform_v0_2_0(arguments)
end
For API tools:
API tools automatically default to version 0.1.0. The version can be specified in the route
setting if needed:
route_setting :mcp, tool_name: :get_issue,
params: [:id, :issue_iid],
version: '1.0.0'
[!note] API tools from routes use a single version per tool. For tools requiring multiple versions, consider implementing as a custom tool instead.
Version support policy:
The framework automatically uses the latest version when no version is specified. Consumers can request specific versions during tool calls. Follow multi-version compatibility guidelines when deprecating versions.
Renaming a tool requires using tool aliases to maintain backward compatibility. Connected clients cache tool names and do not automatically refresh when tools are renamed. The alias system introduced in this merge request allows graceful renames without breaking existing integrations.
Why aliases are necessary:
MCP clients cache the tool list from tools/list and don't automatically re-fetch when tools
change. Renaming a tool causes clients to call a non-existent tool name, resulting in errors
or indefinite hangs. The MCP specification supports notifications/tools/list_changed to notify
clients of changes, but GitLab MCP server doesn't implement this (tracked
in this issue).
Implementation steps:
tool_aliases in your tool class to include the old name:module Mcp
module Tools
class RenamedService < Base::AggregatedService
override :tool_name
def self.tool_name
'new_name'
end
override :tool_aliases
def self.tool_aliases
['old_name']
end
end
end
end
For API tools defined through route_setting :mcp, declare aliases with the tool_aliases:
setting instead of overriding a class method. ApiTool is a single class shared by every route,
so aliases must be per-route:
route_setting :mcp, tool_name: :new_name,
params: [:id],
tool_aliases: [:old_name]
Update all references to use the new tool name:
tool_name:The Mcp::Tools::Manager automatically resolves aliases during get_tool calls, so clients
using the old name continue to work.
Important notes:
list_tools only returns the canonical tool name, not aliasesself.tool_aliases
on the tool class. API tools declare aliases through the tool_aliases: route settingtool_aliases: on a route that also sets aggregators: has no effect: the aggregated tool's
aliases come from the aggregator class's self.tool_aliasesManager#resolve_alias which checks all tool registriesDeprecation timeline:
Release M: Add alias and rename tool Release M+1: Remove alias (after clients have had time to refresh their tool lists)
This approach ensures zero downtime for connected clients during tool renames.
An aggregated tool that folds several operations behind a parameter (for example, an action or
boolean flag) can grow a dedicated list_ tool for its collection-reading action, per the
list/save split convention. Unlike a rename, the call
shape changes (the caller no longer passes the selector parameter), so a tool alias cannot preserve
behavior. Callers using the old action must migrate to the new tool explicitly.
Document the change with a [Removed] entry in the old tool's {{</* history */>}} block in
MCP server tools, noting which action
moved and to which tool, alongside the usual [Introduced] entry for the new tool. See the
list_pipelines and manage_pipeline entries in that page for an example.