main
rb 81 lines 3.08 KB
Raw
1 # frozen_string_literal: true
2
3 module Api
4 module V1
5 # Streamable-HTTP MCP transport for siGit, mounted at /api/v1/mcp.
6 #
7 # The Model Context Protocol is JSON-RPC 2.0. A client POSTs one message
8 # (or a batch) per request; we answer requests with a JSON-RPC response and
9 # acknowledge notifications/responses with 202. This server is stateless
10 # (tool-only, no server->client streaming), which is a valid MCP transport
11 # mode, so we don't issue Mcp-Session-Id or implement the GET SSE stream.
12 class McpController < ActionController::API
13 before_action :authenticate!
14
15 # POST /api/v1/mcp
16 def handle
17 payload = parse_body
18 server = ::Mcp::Server.new(current_user: @current_user)
19
20 case payload
21 when Array # JSON-RPC batch
22 responses = payload.map { |msg| server.handle(msg) }.compact
23 responses.empty? ? head(:accepted) : render(json: responses)
24 when Hash
25 response = server.handle(payload)
26 response.nil? ? head(:accepted) : render(json: response)
27 else
28 render json: rpc_error(nil, -32700, "Parse error"), status: :bad_request
29 end
30 rescue JSON::ParserError
31 render json: rpc_error(nil, -32700, "Parse error"), status: :bad_request
32 end
33
34 # GET /api/v1/mcp — server->client SSE stream. Unused by this stateless
35 # server; advertise that we don't offer one.
36 def stream
37 head :method_not_allowed
38 end
39
40 private
41
42 def parse_body
43 raw = request.body.read
44 raw.present? ? JSON.parse(raw) : nil
45 end
46
47 # Bearer-token auth. siGit's API is token-based against smbCloud: the same
48 # `Authorization: Bearer <smbCloud access token>` the desktop app uses
49 # (see Api::BaseController#authenticate_token!). We validate it against
50 # smbCloud and resolve/mirror the local User. See README for the OAuth 2.1
51 # upgrade path that lets clients authorize via the standard /mcp flow.
52 def authenticate!
53 token = request.authorization.to_s[/\ABearer (.+)\z/, 1]
54 @current_user = token && resolve_user(token)
55 return if @current_user
56
57 # Per MCP/OAuth, a protected resource signals how to authenticate.
58 response.set_header(
59 "WWW-Authenticate",
60 %(Bearer realm="sigit", error="invalid_token", ) +
61 %(resource_metadata="#{request.base_url}/.well-known/oauth-protected-resource")
62 )
63 render json: rpc_error(nil, -32001, "Unauthorized"), status: :unauthorized
64 end
65
66 # Validates the smbCloud access token and upserts the local mirror, exactly
67 # as the rest of the API does. Returns the User or nil when the token is
68 # missing/invalid/unverifiable.
69 def resolve_user(token)
70 profile = SmbcloudAuthService.me(access_token: token)
71 User.find_or_create_from_smbcloud(profile, access_token: token)
72 rescue SmbcloudAuthService::AuthenticationError
73 nil
74 end
75
76 def rpc_error(id, code, message)
77 { jsonrpc: "2.0", id: id, error: { code: code, message: message } }
78 end
79 end
80 end
81 end