Series Context: Part 4 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives.
Featured MR:gitlab-org/gitlab!264856· Work Item:#635913
Turning Existing REST Endpoints into MCP Tools Without Duplicating Business Logic
Not every GitLab Model Context Protocol (MCP) tool needs a custom GraphQL service class under app/services/mcp/tools/. When a Battle-Tested Grape REST endpoint already enforces rate limits, authorization (authorize!), pagination, and entity serialization, GitLab lets you expose it declaratively via Mcp::Tools::Base::ApiTool and API::Concerns::McpAccess.
In !264856, we exposed GET /projects/:id/pipeline_schedules (lib/api/ci/pipeline_schedules.rb) as the read-only list_pipeline_schedules tool inside the :ci toolset. On paper, that takes 11 lines of Ruby. In practice, getting it production-ready uncovered three subtle traps every GitLab MCP contributor should know.
The Declarative Wiring in lib/api/ci/pipeline_schedules.rb
module API
module Ci
class PipelineSchedules < ::API::Base
include ::API::Concerns::McpAccess
include PaginationParams
include APIGuard
before { authenticate! }
allow_mcp_access_read
...
route_setting :mcp,
tool_name: :list_pipeline_schedules,
toolset: :ci,
params: [:id, :scope, :page, :per_page],
annotations: { title: 'List pipeline schedules', readOnlyHint: true, idempotentHint: true,
openWorldHint: false },
resource_name: 'project'
route_setting :authorization, permissions: :read_pipeline_schedule, boundary_type: :project
get ':id/pipeline_schedules' do
authorize! :read_pipeline_schedule, user_project
How this works under the hood (lib/api/concerns/mcp_access.rb & app/services/mcp/tools/base/api_tool.rb):
-
allow_mcp_access_readpermits:mcp-scoped OAuth tokens only forGET/HEADrequests and only when the matched Grape endpoint definesroute_setting :mcp(request.env[Grape::Env::API_ENDPOINT]&.route_setting(:mcp).present?). You cannot accidentally open sibling endpoints in the same API class! -
ApiTool#input_schemareadsroute.params.slice(*settings[:params]), translates types (Grape::API::Boolean,Date, arrays), extracts enums, and wraps the schema withadditionalProperties: false. -
ApiTool#container_argumentsinspectsroute_setting(:authorization)[:boundary_type](:project->{ project: :id }) so Duo workflow tool-governance rules automatically understand the container boundary.
Trap 1: Every MCP Tool Must Declare All Four Annotations Explicitly
During review, master merged a new enforcement spec: spec/services/mcp/tools/annotation_coverage_spec.rb. Watch what it enforces:
RSpec.describe 'MCP tool annotation declarations', feature_category: :ai_agents do
describe 'every registered tool declares a readOnlyHint annotation' do ... end
describe 'every registered tool declares title, idempotentHint, and openWorldHint annotations' do
it 'has no tools missing title, idempotentHint, or openWorldHint' do
booleans = [true, false].freeze
...
missing_keys << :title unless annotations[:title].is_a?(String) && annotations[:title].present?
missing_keys << :idempotentHint unless booleans.include?(annotations[:idempotentHint])
missing_keys << :openWorldHint unless booleans.include?(annotations[:openWorldHint])
end
end
end
And remember: ApiTool#annotations does NOT infer readOnlyHint from HTTP GET:
def annotations
settings[:annotations] || {}
end
Even for a GET route, omitting readOnlyHint, title, idempotentHint, or openWorldHint breaks CI. You must also register those exact annotations in both spec/requests/api/mcp/handlers/list_tools_spec.rb and ee/spec/requests/api/mcp/handlers/list_tools_spec.rb, plus update:
-
ee/spec/lib/ai/tool_rules/governable_tools_container_spec.rb('list_pipeline_schedules' => %w[page per_page scope]) ee/spec/services/ai/duo_workflows/mcp_config_service_spec.rbee/spec/requests/api/ai/duo_workflows/workflows_spec.rb
Trap 2: The Silent 404 Pass in 'an endpoint with mcp route setting'
This catch from reviewer @narendran-kannan is pure gold. Look at spec/support/shared_examples/requests/api/mcp_route_setting_shared_examples.rb:
# Consuming specs must define `user` for the mcp-scoped token test.
RSpec.shared_examples 'an endpoint with mcp route setting' do |expected_tool, expected_params: nil, status: :ok|
...
it 'accepts an mcp-scoped token', :aggregate_failures do
subject
mcp_token = create(:oauth_access_token, user: user, scopes: [:mcp])
...
send(http_method, mcp_path, params: request.request_parameters)
expect(response).not_to have_gitlab_http_status(:forbidden)
expect(response.body).not_to include('insufficient_scope')
end
end
Look closely at what happens in spec/requests/api/ci/pipeline_schedules_spec.rb:
- Top-level
let(:user) { create(:user) }in that file creates a non-member with no access to the private test project! - When
shared_exampleschecksexpect(response).not_to have_gitlab_http_status(:forbidden), a 404 Not Found passes silently! Ifallow_mcp_access_readfailed to pass the token pastfind_project!, or failed only after project resolution, testing with an unprivilegeduserwould never catch it.
Always override let(:user) to an authorized member inside the shared example block:
describe 'mcp route setting' do
let(:user) { developer }
subject { get api(url, developer) }
it_behaves_like 'an endpoint with mcp route setting', :list_pipeline_schedules,
expected_params: [:id, :scope, :page, :per_page]
end
Trap 3: End-to-End tools/call Coverage & Conditional Sensitive Fields
Route-setting specs alone do not verify the full MCP JSON-RPC dispatch (POST /mcp -> tools/call -> ApiTool#execute -> serialized structuredContent). In spec/requests/api/mcp/handlers/call_tool_spec.rb, we tested:
-
Project isolation: Only schedules from the target project are returned (
other_projectschedules excluded). -
Scope filtering (
activeandinactive): Verifies the enum query parameters pass cleanly into Grape. -
Pagination (
page,per_page): Disjoint sets across pages 1 and 2. -
Role-sensitive fields (
inputs): Pipeline schedules can define parameterizedinputsvisible only to the schedule owner, Maintainers, or Owners. When called by a non-ownerdeveloper,inputsis automatically omitted (expect(items.flat_map(&:keys)).not_to include('inputs')), whereas when called by the schedule owner/Maintainer,inputsis present. We documented this exact visibility rule indoc/user/model_context_protocol/mcp_server_tools.md. -
Non-leaky 404 for unauthorized callers: With
resource_name: 'project',ApiToolturns unauthorized access into"404 Project Not Found"without leaking existence of private projects.
(Bonus Note: Both @narendran-kannan and @duo-developer-gitlab-org noted that ApiTool#process_response framework-wide currently drops HTTP X-Next-Page headers, causing has_more metadata to report false. Rather than hacking one route, we opened dedicated follow-up issue #637984 so all route-backed list tools benefit together.)
Takeaways for GitLab Engineers
-
Always declare all 4 MCP annotations (
title,readOnlyHint,idempotentHint,openWorldHint), even onGETGrape routes. -
Define
let(:user) { developer }(ormaintainer) when including'an endpoint with mcp route setting'—never let an unauthorizeduserturn your:mcpscope test into a vacuous 404! -
Keep tool copy sharp: In CI docs, "Lists pipeline schedules" refers to schedule definitions, whereas "scheduled pipelines" means triggered pipeline runs (
list_pipelines(source: 'schedule')).
Series Navigation
-
Part 1: Taming
EE::Project& ExtractingAi::Catalog(!269060) -
Part 2: Decoupling
EE::GroupRouting viaAi::DuoSettings::Paths(!269059) -
Part 3: Escaping Non-ASCII Git Refs in Workspaces — AppSec CWE-78 & CWE-116 (
!262103) -
Part 4 (This Article): Exposing Grape Routes to MCP —
list_pipeline_schedules& Auth Traps (!264856) -
Part 5: Vacuous RSpec Mocks & Live Upstream Flag Rebases (
!269061) -
Part 6: Sequential
description_edits& Quick-Action Defense insave_work_item(!264862) -
Part 7: Self-Correcting MCP Errors & JSON Schema Fixes (
!264852,!264853,!264864) -
Part 8: Consolidating
SavePipelineService& Active Polling Hints (!264858,!264863) -
Part 9: Surviving
Dangerfile, Commit Trailers & Predictive CI Pipelines -
Part 10: Security Cleanups, Organization
empty?& Review Etiquette (!261178,!261030,!264859)
Top comments (0)