Add MCP Orbit API endpoint for Knowledge Graph queries

What does this MR do and why?

Adds a JSON-RPC 2.0 endpoint at POST /api/v4/mcp-orbit for querying the Knowledge Graph via MCP protocol. Proxies tools/list and tools/call to the GKG Rust service over gRPC; handles initialize and notifications/initialized locally in Rails.

This is a separate endpoint from the existing /api/v4/mcp (AI Framework). We need a dedicated server because the Knowledge Graph is a distinct data product with its own GA timeline, monetization for the existing MCP endpoint has not been decided, and bundling KG tools into the existing server would bloat the system prompt by ~3.3k tokens for all consumers. Separating the endpoints lets us manage rate limiting, feature flagging, and beta status independently without touching the AI Framework surface area.

Context: https://gitlab.slack.com/archives/C089YV8KZL1/p1771895586482919

Stacked on !224421 (merged) (gRPC client)

Closes #591397 (closed)

Design decisions

  • Same architecture as the existing MCP server (lib/api/mcp/base.rb) with a separate mcp_orbit namespace
  • CE feature_available? returns false (KG is EE-only); EE prepend checks Feature.enabled?(:knowledge_graph, current_user)
  • tools/list and tools/call forward to the GKG Rust service via Analytics::KnowledgeGraph::GrpcClient
  • initialize and notifications/initialized handled in Rails, no gRPC needed
  • ConnectionError on tools/list returns empty tools array; on tools/call returns JSON-RPC internal error (-32603)
  • ExecutionError on tools/call returns MCP-format error with isError: true
  • Beta endpoint per team discussion

MCP method dispatch

Method Handler Backend
initialize InitializeRequest Local (Rails)
notifications/initialized InitializedNotification Local (no-op)
tools/list ListTools gRPC list_tools
tools/call CallTool gRPC execute_tool
* - JSON-RPC -32601

Authentication

  1. authenticate! -- valid OAuth token required
  2. not_found! unless feature_available? -- EE checks :knowledge_graph feature flag
  3. forbidden! unless token has mcp scope

How to set up and validate locally

Checkout the branch and start GDK:

git checkout orbit-mcp-api
gdk restart rails-web

Wait for https://gdk.test:3443/-/health to return 200.

Set up test credentials

In bin/rails c:

user = User.find_by(admin: true)
Feature.enable(:knowledge_graph, user)

app = Authn::OauthApplication.create(
  name: 'MCP Orbit Test',
  redirect_uri: 'http://127.0.0.1:3334/callback',
  scopes: 'mcp_orbit',
  confidential: false,
  owner: user
)

token = Doorkeeper::AccessToken.new(
  application_id: app.id,
  resource_owner_id: user.id,
  scopes: 'mcp_orbit',
  expires_in: 7200,
  organization: Organizations::Organization.first
)
token.save!

puts "TOKEN=#{token.plaintext_token}"

Then export it:

export TOKEN="<paste token here>"

1. MCP initialize handshake

curl -sk -X POST https://gdk.test:3443/api/v4/mcp_orbit \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":"1","params":{"protocolVersion":"2025-06-18"}}'

You should get back protocolVersion, capabilities.tools.listChanged: false, and serverInfo.name: "GitLab Orbit MCP Server".

2. List tools

curl -sk -X POST https://gdk.test:3443/api/v4/mcp_orbit \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":"2"}'

Should return two tools: query_graph (full DSL schema in the description) and get_graph_schema (with expand_nodes parameter). Both come from the Rust gRPC service via ListTools.

3. Call a tool (needs a running GKG stack)

curl -sk -X POST https://gdk.test:3443/api/v4/mcp_orbit \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":"3","params":{"name":"query_graph","arguments":{"query":{"query_type":"search","node":{"id":"u","entity":"User"},"limit":5}}}}'

With GKG running: isError: false with query results in content[0].text. Without GKG: JSON-RPC internal error (code: -32603).

4. Unknown tool rejection

curl -sk -X POST https://gdk.test:3443/api/v4/mcp_orbit \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":"4","params":{"name":"bad_tool","arguments":{}}}'

Should return -32602 (invalid params) with "Unknown tool: bad_tool". Only query_graph and get_graph_schema are accepted.

5. Scope isolation

Create a token with mcp scope only and verify it gets rejected:

# In rails console
mcp_token = Doorkeeper::AccessToken.new(
  application_id: app.id,
  resource_owner_id: user.id,
  scopes: 'mcp',
  expires_in: 7200,
  organization: Organizations::Organization.first
)
mcp_token.save!
puts "MCP_TOKEN=#{mcp_token.plaintext_token}"
curl -sk -X POST https://gdk.test:3443/api/v4/mcp_orbit \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":"5","params":{"protocolVersion":"2025-06-18"}}'

Should return HTTP 403 with {"error":"insufficient_scope",...}. A mcp token does not work on the mcp_orbit endpoint.

6. OAuth discovery

curl -sk https://gdk.test:3443/.well-known/oauth-authorization-server/api/v4/mcp_orbit

Should return OAuth metadata with scopes_supported: ["mcp_orbit"] and a registration_endpoint.

7. mcp-remote end-to-end (optional)

echo '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","method":"tools/list","id":2}' | \
  NODE_TLS_REJECT_UNAUTHORIZED=0 npx -y mcp-remote \
    "https://gdk.test:3443/api/v4/mcp_orbit" \
    --header "Authorization: Bearer $TOKEN"

Two JSON-RPC responses on stdout (initialize and tools/list). The proxy connects over StreamableHTTPClientTransport.

References

MR acceptance checklist

This MR was evaluated against the MR acceptance checklist.

Edited by Michael Angelo Rivera

Merge request reports

Loading