Register Orbit tools in the main GitLab MCP server (/api/v4/mcp)
### Problem to Solve
Orbit tools live only on the separate Orbit MCP server at `/api/v4/orbit/mcp`. The MCP team wants them in the main GitLab MCP server at `/api/v4/mcp` for their MCP GA in 19.5. Agreed in [&23430](https://gitlab.com/groups/gitlab-org/-/work_items/23430). Cutoff: October 15.
Today the main server runs tools in process and returns JSON-RPC directly. It has no branch for the Workhorse header that streams an Orbit query result. It has no Orbit tool registration. Everything else we need already exists and is reusable.
```plantuml
@startuml
skinparam sequenceMessageAlign center
actor Agent
participant "Rails /api/v4/mcp" as Rails
participant Workhorse
participant "Orbit server" as Orbit
== tools/list ==
Agent -> Rails: tools/list
Rails -> Orbit: ListAgentCommands (cached)
Orbit --> Rails: names, descriptions, schemas
Rails --> Agent: GitLab tools + Orbit tools
== tools/call query_graph ==
Agent -> Rails: tools/call
Rails -> Rails: license gate, rate limit
Rails --> Workhorse: send-data header (existing helper)
Workhorse -> Orbit: ExecuteQuery stream
Orbit -> Workhorse: redaction request
Workhorse -> Rails: /internal/orbit/redaction
Workhorse --> Agent: JSON-RPC result
== tools/call other Orbit tool ==
Agent -> Rails: tools/call
Rails -> Orbit: InvokeAgentCommand
Rails --> Agent: JSON-RPC result
@enduml
```
### Proposed Solution
Rails does not hardcode any Orbit tool. One generic wrapper class turns each command that the Orbit server returns from `ListAgentCommands` into a main-server tool. This mirrors how the manager already discovers REST-route tools.
- Add an Orbit discovery step to the tool manager. It calls `ListAgentCommands`, caches the result, and builds one wrapper per command. Names, descriptions, and input schemas come from the Orbit server.
- The wrapper gates on `Analytics::KnowledgeGraph.accessible_for?` in both `available?` and `perform`, so a direct `tools/call` cannot bypass the license check.
- The wrapper reuses `CommandInterceptor`. `query_graph` returns the Workhorse send-data header. Other commands call `InvokeAgentCommand`.
- The main server `post` block gets the same send-data branch the Orbit endpoint has. Workhorse needs no change. Its Orbit injecter sits on the generic Rails proxy and already wraps results in a JSON-RPC envelope.
- Rate limit `:orbit_query` on the query tool. Add an `orbit` toolset, opt in.
- Ship the schema-stripped descriptions first. They are on Orbit main but not yet released.
Out of scope: retiring `/api/v4/orbit/mcp`. Duo Workflow Service is wired to that path in Workhorse and keeps using it.
<details>
<summary><b>Agent context</b> — extended analysis, alternatives considered, related-code dumps</summary>
Source state: gitlab master `6a9dcd6c50d9`, knowledge-graph main `3d46fe379` (2026-09-16).
What already works:
- Descriptions come from Orbit over gRPC. The Orbit MCP handlers relay [`ListTools` and `ListAgentCommands`](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/blob/3d46fe379/crates/orbit-server/proto/orbit.proto#L37-41). Rails hardcodes nothing today.
- The 23 KB DSL was removed from the `query_graph` description in knowledge-graph commit `153d09d64` (2026-09-11). A test caps every description under 400 chars. Not in v0.121.0.
- The Workhorse `orbit-query` injecter is registered on the generic proxy at [routes.go#L307](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/workhorse/internal/upstream/routes.go#L307). It fires for any Rails route. It wraps the result as JSON-RPC when `McpId` is set ([sendquery.go#L293-298](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/workhorse/internal/orbit/sendquery.go#L293-298)).
- `source_type` is `mcp` or `dws` based on the `ai_workflows` OAuth scope, not the URL ([source_type.rb#L30-35](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/ee/lib/analytics/knowledge_graph/source_type.rb#L30-35)). It rides in the JWT claim that Orbit meters on. Billing is not affected.
- The `mcp` OAuth scope is already accepted by the Orbit endpoint. Both scopes map to the same ability ([auth.rb#L344-345](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/lib/gitlab/auth.rb#L344-345)).
- Toolsets are declared per tool (work item 607755) but nothing filters by toolset at request time yet ([toolsets.rb](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/app/services/mcp/tools/toolsets.rb)).
What the main server lacks:
- The `post` block always calls `format_jsonrpc_response` ([base.rb#L203-219](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/lib/api/mcp/base.rb#L203-219)). The Orbit endpoint's branch is at [mcp.rb#L146-152](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/ee/lib/api/orbit/mcp.rb#L146-152).
- `available?` runs on `tools/list` only ([base_service.rb#L65](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/app/services/mcp/tools/base/base_service.rb#L65)). EE tools gate inside the call instead.
- `EE_CUSTOM_TOOLS` is empty; REST tools are discovered from `route_setting :mcp` ([manager.rb#L217-231](https://gitlab.com/gitlab-org/gitlab/-/blob/6a9dcd6c50d9/app/services/mcp/tools/manager.rb#L217-231)). The Orbit discovery step follows that pattern.
- No rate limit on `tools/call`.
MRs, all in gitlab-org/gitlab:
1. Orbit discovery step and wrapper class in the tool manager, cached `ListAgentCommands`, license gate, toolset, specs.
2. Send-data branch in `base.rb`, `McpId` plumbing, `:orbit_query` rate limit. MCP team review.
3. Feature flag and docs (`doc/development/duo_agent_platform/mcp/`, `doc/integration/orbit.md`, user MCP docs).
4. Optional and generic: make `CallTool` honor `available?` for every tool.
Prerequisite in knowledge-graph: release `153d09d64` before MR 1 reaches production.
Decisions:
- Tool names. The Orbit server owns them. If the MCP team wants an `orbit_` prefix or `verb_object` names, the change lands in `config/prompts/remote/tools/` and `registry.rs`, not in Rails.
- `source_type`. Keep `mcp`. Measure adoption from the main server's `tool_call` log.
- Toolset default. Opt in until toolset filtering ships.
Alternative rejected: one `CustomService` class per Orbit tool. It would copy names and schemas into Rails and need a Rails MR for every Orbit change, which ADR 011 was written to avoid.
Estimate: about 2 weeks for one engineer, 3 with review latency.
</details>
<!-- AI-Sessions
dir: ~/.claude/projects/-Users-angelo-rivera-gitlab-orbit-knowledge-graph/
d641cea2-f736-465e-975d-ff343fc9f51f.jsonl (2026-09-16)
-->
issue
GitLab AI Context
Project: gitlab-org/orbit/knowledge-graph
Instance: https://gitlab.com
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/raw/main/CONTRIBUTING.md — contribution guidelines
- https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/raw/main/README.md — project overview and setup
- https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/raw/main/AGENTS.md — AI agent instructions
- https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/raw/main/CLAUDE.md — Claude Code instructions
Repository: https://gitlab.com/gitlab-org/orbit/knowledge-graph
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD