Dev.to AI 🤖 Ai 👁 0 📖 5 min read

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 Witho

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):

  1. 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!
  2. 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.
  3. 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.rb
  • ee/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:

  1. Project isolation: Only schedules from the target project are returned (other_project schedules excluded).
  2. Scope filtering (active and inactive): Verifies the enum query parameters pass cleanly into Grape.
  3. Pagination (page, per_page): Disjoint sets across pages 1 and 2.
  4. 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, inputs is automatically omitted (expect(items.flat_map(&:keys)).not_to include('inputs')), whereas when called by the schedule owner/Maintainer, inputs is present. We documented this exact visibility rule in doc/user/model_context_protocol/mcp_server_tools.md.
  5. Non-leaky 404 for unauthorized callers: With resource_name: 'project', ApiTool turns 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

  1. Always declare all 4 MCP annotations (title, readOnlyHint, idempotentHint, openWorldHint), even on GET Grape routes.
  2. Define let(:user) { developer } (or maintainer) when including 'an endpoint with mcp route setting'—never let an unauthorized user turn your :mcp scope test into a vacuous 404!
  3. 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

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.