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

  1. When an LLM passed { unexpected_argument: "foo" }, JSONSchemer emitted an error with type == "schema" and schema_pointer == "/additionalProperties". That fell into the else branch 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!
  2. On 'required' errors, error['details']['missing_keys'].first silently 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_map on missing_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

  1. Make validation errors teachable: Always tell the MCP caller what key was unexpected and what keys are valid in the same error string.
  2. Put minimal valid examples ({"scope": "...", "search": "..."}) in line 2 of tool descriptions.
  3. Never emit Grape Ruby class names verbatim into JSON Schema: Always audit generated inputSchema output for non-standard primitive types like "date".

Series Navigation