Part 4: Exposing Grape REST Endpoints to GitLab's MCP Server — list_pipeline_schedules, Annotations & Auth Spec Traps
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)