Part 7: Self-Correcting MCP Errors, Required-Param Prompts & Strict JSON Schema Fixes in GitLab
Series Context: Part 7 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives. Featured MRs: !264852, !264853, !264864 (All Merged into master) Designing Tools for LLM Clients Is Different F
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)
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.