Series Context: Part 7 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives.
Featured MRs:!264852,!264853,!264864(All Merged intomaster)
Designing Tools for LLM Clients Is Different From Writing REST Docs
When a human developer hits 400 Bad Request on a REST API, they open browser docs or curl logs. When an AI agent calls an MCP tool and gets "Validation error: unexpected_argument is invalid", the model only sees that single string in its next reasoning step.
If an MCP error omits which parameter names are valid—or emits non-standard JSON Schema types—clients fail silently or burn 3–5 round trips guessing. Three recent merged MRs fixed high-leverage friction points across GitLab's MCP stack.
1. Self-Correcting JSONSchemer Validation Errors (!264853, Merged)
Look at how Mcp::Tools::Base::BaseService#validate_arguments! previously formatted JSONSchemer errors when additionalProperties: false rejected a misspelled parameter:
# Before (app/services/mcp/tools/base/base_service.rb) validations = errors.map do |error| case error['type'] when 'required' "#{error['details']['missing_keys'].first} is missing" when 'maxItems' ... when 'enum' ... else field = error['data_pointer'].sub(%r{^/}, '') "#{field} is invalid" end end.uniq raise ArgumentError, validations.join(', ') Spot two problems hiding right there:
- When an LLM passed
{ unexpected_argument: "foo" },JSONSchemeremitted an error withtype == "schema"andschema_pointer == "/additionalProperties". That fell into theelsebranch and told the client only:Validation error: unexpected_argument is invalid— without saying it was an unknown parameter and without telling the agent what parameters are accepted! - On
'required'errors,error['details']['missing_keys'].firstsilently dropped the 2nd and 3rd missing required keys if multiple fields were omitted.
Here is the refactored implementation shipped in !264853:
def validate_arguments!(arguments) schemer = JSONSchemer.schema(input_schema) json_arguments = arguments ? arguments.deep_stringify_keys : {} errors = schemer.validate(json_arguments).to_a return if errors.empty? validations = errors.flat_map { |error| format_validation_error(error) }.uniq validations << accepted_parameter_hint if errors.any? { |error| unknown_parameter_error?(error) } raise ArgumentError, validations.join('; ') end def format_validation_error(error) field = error['data_pointer'].sub(%r{^/}, '') return "#{field} is invalid: unknown parameter" if unknown_parameter_error?(error) case error['type'] when 'required' error['details']['missing_keys'].map { |key| "#{key} is missing" } when 'maxItems' "#{field} cannot contain more than #{error['schema']['maxItems']} items" when 'enum' "Invalid #{field}: '#{error['data']}'. Must be one of: #{error['schema']['enum'].join(', ')}" else "#{field} is invalid" end end def unknown_parameter_error?(error) error['schema_pointer'] == '/additionalProperties' && error['type'] == 'schema' end def accepted_parameter_hint accepted = accepted_parameter_names return 'This tool takes no parameters' if accepted.empty? "Accepted parameters: #{accepted.join(', ')}" end def accepted_parameter_names properties = input_schema[:properties] || input_schema['properties'] || {} properties.keys.map(&:to_s) end Now look at what an AI client receives in a single failed turn:
Validation error: unknown_param is invalid: unknown parameter; required_field is missing; Accepted parameters: required_field, optional_field, items_field, enum_field Notice every edge case handled cleanly:
- Multiple unknown parameters (
unknown_one,unknown_two): Each unknown key is named individually, while"Accepted parameters: ..."appears only once at the end. - Zero-argument tools (
accepted.empty?): Emits'This tool takes no parameters'instead of a dangling empty list. -
flat_maponmissing_keys: Reports every missing required parameter in one pass.
2. Leading Tool Descriptions With Required Parameters (!264852, Merged)
Even with strict schemas, weaker or fast tool-calling models pay disproportionately higher attention to the opening two sentences of an MCP tool's description.
In app/services/mcp/tools/search/search_service.rb, the description_parts array led with "Search across GitLab with automatic selection of the best available search method." followed immediately by long capability strings and syntax guides (filename:*.rb, extension:js). Agents frequently invoked search with guessed parameter names like query or omitted scope.
!264852 added one single, high-signal second line upfront:
def description_parts [ 'Search across GitLab with automatic selection of the best available search method.', 'Requires `scope` and `search` (for example, `{"scope": "work_items", "search": "rate limit"}`).', '', "**Capabilities:** #{search_capabilities.join(', ')}", ... Small diff—massive reduction in first-turn search schema mistakes!
3. Fixing Grape Date Schema Type & Adding ungovernable: true for User-Scoped API Tools (!264864, Merged)
When exposing GET /users/:id/events (lib/api/events.rb) as the list_user_events MCP tool in !264864, generating the tool's inputSchema hit two framework bugs in Mcp::Tools::Base::ApiTool (app/services/mcp/tools/base/api_tool.rb):
Bug A: Grape Date Emitted Invalid "type": "date" in JSON Schema
The events endpoint declares query parameters before and after with Grape type Date. ApiTool::TYPE_CONVERSIONS mapped 'DateTime' => 'string', but did not include 'Date', so parse_type fell through to type.downcase and emitted:
{ "before": { "type": "date" } } "date" is not a valid JSON Schema primitive type (JSON Schema defines string with optional format, never "date"). Strict MCP hosts (IDE clients, SDK validators) reject the entire tools/list response when any tool schema fails JSON Schema validation! Adding 'Date' => 'string' fixed schema conformance globally for every future Grape endpoint with Date params:
TYPE_CONVERSIONS = { '[String, Integer]' => 'string', 'Grape::API::Boolean' => 'boolean', '[Integer]' => { type: 'array', items: { type: 'integer' } }, '[String]' => 'string', 'Date' => 'string', 'DateTime' => 'string' }.freeze Bug B: User-Scoped Endpoints vs Project/Group Governance (ungovernable: true)
ApiTool expects container-governed tools to resolve either a :project or :group boundary via GovernanceContainerResolver. However, /users/:id/events has boundary_type: :user—it operates across user activity, not a single project or group!
Adding declarative ungovernable: true support on route_setting :mcp cleanly bypasses container checks for user-scoped tools without duplicating governance overrides:
# app/services/mcp/tools/base/api_tool.rb override :ungovernable? def ungovernable? return true if settings[:ungovernable] super end override :container_arguments def container_arguments return {} if ungovernable? ... end And documented right alongside service-based tools in doc/development/duo_agent_platform/mcp/_index.md!
Takeaways for GitLab Engineers
- Make validation errors teachable: Always tell the MCP caller what key was unexpected and what keys are valid in the same error string.
- Put minimal valid examples (
{"scope": "...", "search": "..."}) in line 2 of tool descriptions. - Never emit Grape Ruby class names verbatim into JSON Schema: Always audit generated
inputSchemaoutput for non-standard primitive types like"date".
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: 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 (This Article): 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)
