Register Orbit tools in the main MCP server

What does this MR do and why?

This MR adds the Orbit tools to the main GitLab MCP server at POST /api/v4/mcp. The tools are in a new opt-in toolset, orbit. It is not a default toolset. Amanda Rueda made this decision on 2026-10-06 (see the Slack thread copy).

Terms:

  • Orbit command: a function of the Orbit server, for example query_graph or get_graph_schema. The Orbit server returns the list of commands from ListAgentCommands.
  • Orbit tool: one MCP tool for each Orbit command. The Orbit server sets the name, the description, and the input schema. Rails hard-codes no Orbit tool.

The Orbit gate uses the same checks as the Orbit endpoints. The tools are listed and run only when all of these conditions are true.

  1. Orbit is set up on the instance, and the user has Orbit access (accessible_for?). This covers the licence, the plan tier on GitLab.com, the orbit_promo add-on, and Orbit trials.
  2. The user has at least the Reporter role in a group that has Orbit turned on (has_enabled_namespaces?).
  3. A fine-grained token has the read_orbit permission, as on the Orbit endpoints.

A user who fails the gate does not see the tools. A direct tools/call returns Tool 'query_graph' not found.

Changes
  • Mcp::Tools::RequestContext is a typed Data object with current_user, access_token, and request. lib/api/mcp/base.rb builds it once for each request and passes it as context: to the handlers and the manager.
  • Mcp::Tools::Manager gets a user_tools(context, toolsets) hook. EE fills it only when the request selects the orbit toolset, or when tools/call names a tool that is not static. Static tools win on a name or alias clash. Callers with no context get only the static tools.
  • CommandCatalog runs the gate and builds one CommandTool for each command. It caches the command list in Rails.cache for 10 minutes, one entry per query language. If the Orbit server is down, the other tools are still listed.
  • CommandInterceptor#invoke_orbit_command is now the one place that runs an Orbit command: query_graph returns an OrbitQuery, and other commands call InvokeAgentCommand. The new tools, /api/v4/orbit/mcp, and the /api/v4/orbit agent command routes all use it. This removes two copies of the same code and the InterceptResult and CommandResponse structs.
  • render_mcp_result in lib/api/mcp/base.rb renders the JSON-RPC result. The EE override sends an OrbitQuery to render_orbit_query!, as the Orbit endpoints do. So orbit_query_in_rails decides if Rails or Workhorse runs the query stream.
  • RequestContext.for_mcp_request sets the billing source_type the same way for both MCP endpoints. A token with the ai_workflows scope counts as dws.
  • ConnectionError::GENERIC_MESSAGE holds the generic gRPC error text in one place. A client error with details returns the Orbit error details. Any other error returns the generic message.
  • ListAgentCommands has a 2 second deadline, so a slow Orbit server does not hold tools/list.
  • CommandTool decodes a JSON DSL query that a client sends as a JSON string, as /api/v4/orbit/mcp does.

Toolset headers filter only tools/list, as for the other opt-in toolsets. A direct tools/call of an Orbit tool still runs the full gate.

Duo Workflow Service is not changed. It keeps using /api/v4/orbit/mcp. It builds its gitlab tool list from the static Manager#list_tools and sends no toolset header, so it never gets Orbit tools from /api/v4/mcp.

#631630 plans to pass the access token to MCP tools. With this MR, that work can read context.access_token.

References

Screenshots or screen recordings

No UI changes.

How to set up and validate locally

Tested end to end on a GDK with the full Orbit stack (Orbit server, Siphon, ClickHouse, Workhorse) in SaaS mode. No stubs.

  1. Make two personal access tokens with the api and mcp scopes: one for a user with Orbit, and one for a user with no Orbit-enabled group.

  2. Send tools/list with and without the toolset header:

    curl -sk https://gdk.test:3443/api/v4/mcp -H "Authorization: Bearer $TOKEN" \
      -H 'Content-Type: application/json' -H 'X-Gitlab-Enabled-Mcp-Server-Toolsets: orbit' \
      -d '{"jsonrpc":"2.0","method":"tools/list","id":"1"}' | jq '[.result.tools[].name]'
  3. Call query_graph:

    curl -sk https://gdk.test:3443/api/v4/mcp -H "Authorization: Bearer $TOKEN" \
      -H 'Content-Type: application/json' \
      -d '{"jsonrpc":"2.0","method":"tools/call","id":7,"params":{"name":"query_graph","arguments":{"query":{"query_type":"traversal","nodes":[{"id":"p","entity":"Project","filters":{"full_path":{"starts_with":"gitlab-org/"}},"columns":["name","full_path"]}],"limit":3}}}}'
End-to-end results

tools/list:

Token No header orbit all
Admin with Orbit 41 tools, no Orbit tools 5 tools: meta + 4 Orbit tools 50 tools, 4 Orbit tools
User with no Orbit-enabled group 36 tools, no Orbit tools 1 tool (meta) 41 tools, no Orbit tools

The 4 Orbit tools are query_graph, get_graph_schema, get_query_dsl, and get_response_format. Each has the readOnlyHint annotation. Each description is 268 characters or less.

tools/call:

  • query_graph with integer id 7 returns real rows: {"jsonrpc":"2.0","id":7,"result":{"isError":false,...}} with 3 projects. This is true with orbit_query_in_rails on and off, and with the JSON DSL and GQL. The send-data header does not reach the client.
  • query_graph with the JSON DSL query as a string returns the same 3 projects.
  • A class pushed to a new project is found through query_graph about 60 seconds after the push.
  • get_graph_schema with no arguments returns the 1,310-character schema.
  • The user with no Orbit-enabled group gets -32602 Tool 'query_graph' not found.
  • A missing query returns the tool error Validation error: query is missing.

Gate states for a non-admin owner of one group, with the live stack. Each row checks tools/list and tools/call.

State Tools listed Call
No Orbit-enabled group No Not found
Orbit turned on, promo gate off Yes Runs
Promo gate on, no add-on, no trial No Not found
Promo gate on, trial in progress Yes Runs
Promo gate on, trial ended No Not found
Promo gate on, active orbit_promo add-on Yes Runs
Promo gate on, expired orbit_promo add-on No Not found
Fine-grained token without read_orbit No Not found
Fine-grained token with read_orbit Yes Runs

This table shows the database queries for tools/list with no header, from api_json.log in the steady state.

Token master This branch
Admin with Orbit 24 24
User with no Orbit-enabled group 12 12

Specs after the rebase: 550 examples, 0 failures across the MCP, Orbit MCP, Orbit REST, and Orbit tool specs.

I checked the existing paths again on this commit with real Orbit data.

  • /api/v4/orbit/mcp: initialize, tools/list, list_commands, get_graph_schema, and query_graph all return 200 with real rows, with an mcp token and with an mcp_orbit token.
  • /api/v4/orbit agent commands: query_graph, get_graph_schema, and get_query_dsl return 200 with the same body shapes as on master.
  • Duo Workflow Service agent chat: the agent calls orbit_list_commands, then invoke_command for get_graph_schema, get_query_dsl, and query_graph, and answers with real projects. All Orbit calls go to /api/v4/orbit/mcp.
  • A replay of the Duo Workflow Service tools/list call to /api/v4/mcp, with a real ai_workflows token, returns no Orbit tools. This holds with no tools header and with the header for an agent that selects all 56 tools. It also holds when the 4 Orbit names are added to that header.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Michael Angelo Rivera

Merge request reports

Loading
Loading