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:๐ ๏ธ: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_read permits :mcp-scoped OAuth tokens only for GET / HEAD requests and only when the matched Grape endpoint defines route_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_schema reads route.params.slice(*settings[:params]), translates types (Grape::API::Boolean, Date, arrays), extracts enums, and wraps the schema with additionalProperties: false.
ApiTool#container_arguments inspects route_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_examples checks expect(response).not_to have_gitlab_http_status(:forbidden), a 404 Not Found passes silently!
If allow_mcp_access_read failed to pass the token past find_project!, or failed only after project resolution, testing with an unprivileged user would 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 parameterized inputs visible only to the schedule owner, Maintainers, or Owners. When called by a non-owner developer,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 }(or maintainer) when including 'an endpoint with mcp route setting' - never let an unauthorized user turn 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 & Extracting Ai::Catalog (
!269060) - Part 2: Decoupling EE::Group Routing via Ai::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 in save_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)
Comments
No comments yet. Start the discussion.