From dea07675e0d66f85a012fe6dbb3027b463770f75 Mon Sep 17 00:00:00 2001 From: Igor Drozdov Date: Tue, 19 Aug 2025 01:20:27 +0200 Subject: [PATCH 1/6] Define an MCP tool via API setting We can get MCP definition from the OpenAPI route description It'll allow us to avoid duplication Also, calling a route directly as a middleware would allow us to avoid generating an oauth token everytime --- lib/api/api.rb | 2 +- lib/api/issues.rb | 1 + lib/api/mcp/base.rb | 2 +- lib/api/mcp/handlers/base.rb | 2 +- lib/api/mcp/handlers/call_tool_request.rb | 17 +++++------ lib/api/mcp/handlers/initialize_request.rb | 2 +- .../initialized_notification_request.rb | 2 +- lib/api/mcp/handlers/list_tools_request.rb | 30 ++++++++++++++----- lib/api/merge_requests.rb | 1 + 9 files changed, 37 insertions(+), 22 deletions(-) diff --git a/lib/api/api.rb b/lib/api/api.rb index 07a204f7ed4513..1e1ac840eecc1f 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -288,6 +288,7 @@ def initialize(location_url) mount ::API::Integrations::JiraConnect::Subscriptions mount ::API::Invitations mount ::API::IssueLinks + mount ::API::Issues mount ::API::Keys mount ::API::Lint mount ::API::Markdown @@ -375,7 +376,6 @@ def initialize(location_url) mount ::API::GroupBoards mount ::API::GroupLabels mount ::API::GroupMilestones - mount ::API::Issues mount ::API::Labels mount ::API::Mcp::Base # MCP uses JSON-RPC for base protocol, omit from OpenAPI V2 documentation for REST API mount ::API::Notes diff --git a/lib/api/issues.rb b/lib/api/issues.rb index b285fe2a424288..9a3b98985f4625 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -256,6 +256,7 @@ class Issues < ::API::Base params do requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' end + route_setting :mcp, name: :get_issue, params: { 'id' => 'string', 'issue_iid' => 'integer' } get ":id/issues/:issue_iid", as: :api_v4_project_issue do issue = find_project_issue(params[:issue_iid]) present issue, with: Entities::Issue, current_user: current_user, project: user_project diff --git a/lib/api/mcp/base.rb b/lib/api/mcp/base.rb index 9b75608f3acf60..85a5e8734e1ece 100644 --- a/lib/api/mcp/base.rb +++ b/lib/api/mcp/base.rb @@ -127,7 +127,7 @@ def format_jsonrpc_response(result) handler_class = find_handler_class(params[:method]) handler = create_handler(handler_class, params[:params] || {}) - result = handler.invoke + result = handler.invoke(request) format_jsonrpc_response(result) end diff --git a/lib/api/mcp/handlers/base.rb b/lib/api/mcp/handlers/base.rb index 42c2dcf17c529d..5779d873ebc48c 100644 --- a/lib/api/mcp/handlers/base.rb +++ b/lib/api/mcp/handlers/base.rb @@ -10,7 +10,7 @@ def initialize(params) @params = params end - def invoke + def invoke(_) raise NoMethodError end end diff --git a/lib/api/mcp/handlers/call_tool_request.rb b/lib/api/mcp/handlers/call_tool_request.rb index ec038ebf6961e4..8a02b4ccd84205 100644 --- a/lib/api/mcp/handlers/call_tool_request.rb +++ b/lib/api/mcp/handlers/call_tool_request.rb @@ -10,20 +10,17 @@ def initialize(params, access_token) @access_token = access_token end - def invoke + def invoke(request) validate_params - tool_klass = ListToolsRequest::TOOLS[params[:name]] - raise ArgumentError, 'name is unsupported' unless tool_klass + route = ::API::API.routes.find { |route| route.app.route_setting(:mcp).try(:dig, :name).to_s == params[:name] } + raise ArgumentError, 'name is unsupported' unless route - tool = tool_klass.new(name: params[:name]) + request.env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(params[:arguments]) - begin - tool.execute(access_token, params[:arguments]) - rescue StandardError => e - # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#calltoolresult-iserror - ::Mcp::Tools::Response.error(e.message) - end + _status, _headers, body = route.exec(request.env) + + body end private diff --git a/lib/api/mcp/handlers/initialize_request.rb b/lib/api/mcp/handlers/initialize_request.rb index e2eeed511381f5..df2cc5af9da24c 100644 --- a/lib/api/mcp/handlers/initialize_request.rb +++ b/lib/api/mcp/handlers/initialize_request.rb @@ -5,7 +5,7 @@ module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#initializerequest class InitializeRequest < Base - def invoke + def invoke(_) { protocolVersion: '2025-06-18', capabilities: { diff --git a/lib/api/mcp/handlers/initialized_notification_request.rb b/lib/api/mcp/handlers/initialized_notification_request.rb index 2e7ea69f6ab659..dda81e0f63d422 100644 --- a/lib/api/mcp/handlers/initialized_notification_request.rb +++ b/lib/api/mcp/handlers/initialized_notification_request.rb @@ -5,7 +5,7 @@ module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#initializednotification class InitializedNotificationRequest < Base - def invoke + def invoke(_) # JSON-RPC notifications are one-way messages nil end diff --git a/lib/api/mcp/handlers/list_tools_request.rb b/lib/api/mcp/handlers/list_tools_request.rb index 59c4bd70716bf6..bf4008bc16c8e2 100644 --- a/lib/api/mcp/handlers/list_tools_request.rb +++ b/lib/api/mcp/handlers/list_tools_request.rb @@ -21,17 +21,33 @@ class ListToolsRequest < Base }.freeze TOOLS = CUSTOM_TOOLS.merge(API_TOOLS) - def invoke + def invoke(_) + tools = ::API::API.routes.select { |route| route.app.route_setting(:mcp) }.map do |route| + mcp_settings = route.app.route_setting(:mcp) + + { + name: mcp_settings[:name], + description: route.description, + inputSchema: { + type: 'object', + properties: route.params.slice(*mcp_settings[:params].keys).to_h do |key, value| + [key, { + type: mcp_settings[:params][key], + description: value[:desc] + }] + end, + required: route.params.filter_map do |param, values| + param if values[:required] + end, + additionalProperties: false + } + } + end + { tools: tools } end - - private - - def tools - TOOLS.map { |tool_name, tool_klass| tool_klass.new(name: tool_name).to_h } - end end end end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 446d399121f7a0..91179d450c0b35 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -398,6 +398,7 @@ def batch_process_mergeability_checks(merge_requests) ] tags %w[merge_requests] end + route_setting :mcp, name: :get_merge_request, params: { 'id' => 'string', 'merge_request_iid' => 'integer' } get ':id/merge_requests/:merge_request_iid', feature_category: :code_review_workflow, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) -- GitLab From 68def8e0225c78a3fa9545d9bda318718e80ee1c Mon Sep 17 00:00:00 2001 From: Igor Drozdov Date: Tue, 19 Aug 2025 13:54:12 +0200 Subject: [PATCH 2/6] Extract MCP routes in a separate file --- lib/api/api.rb | 1 + lib/api/issues.rb | 2 +- lib/api/mcp/base.rb | 2 +- lib/api/mcp/handlers/base.rb | 2 +- lib/api/mcp/handlers/call_tool.rb | 52 +++++++ lib/api/mcp/handlers/call_tool_request.rb | 17 ++- lib/api/mcp/handlers/initialize_request.rb | 2 +- .../initialized_notification_request.rb | 2 +- lib/api/mcp/handlers/list_tools.rb | 61 ++++++++ lib/api/mcp/handlers/list_tools_request.rb | 30 +--- lib/api/mcp/server.rb | 133 ++++++++++++++++++ lib/api/merge_requests.rb | 2 +- 12 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 lib/api/mcp/handlers/call_tool.rb create mode 100644 lib/api/mcp/handlers/list_tools.rb create mode 100644 lib/api/mcp/server.rb diff --git a/lib/api/api.rb b/lib/api/api.rb index 1e1ac840eecc1f..8f1f3fc72b965f 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -378,6 +378,7 @@ def initialize(location_url) mount ::API::GroupMilestones mount ::API::Labels mount ::API::Mcp::Base # MCP uses JSON-RPC for base protocol, omit from OpenAPI V2 documentation for REST API + mount ::API::Mcp::Server mount ::API::Notes mount ::API::NotificationSettings mount ::API::ProjectEvents diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 9a3b98985f4625..903c3379f2b849 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -256,7 +256,7 @@ class Issues < ::API::Base params do requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' end - route_setting :mcp, name: :get_issue, params: { 'id' => 'string', 'issue_iid' => 'integer' } + route_setting :mcp, name: :get_issue, params: [:id, :issue_iid] get ":id/issues/:issue_iid", as: :api_v4_project_issue do issue = find_project_issue(params[:issue_iid]) present issue, with: Entities::Issue, current_user: current_user, project: user_project diff --git a/lib/api/mcp/base.rb b/lib/api/mcp/base.rb index 85a5e8734e1ece..9b75608f3acf60 100644 --- a/lib/api/mcp/base.rb +++ b/lib/api/mcp/base.rb @@ -127,7 +127,7 @@ def format_jsonrpc_response(result) handler_class = find_handler_class(params[:method]) handler = create_handler(handler_class, params[:params] || {}) - result = handler.invoke(request) + result = handler.invoke format_jsonrpc_response(result) end diff --git a/lib/api/mcp/handlers/base.rb b/lib/api/mcp/handlers/base.rb index 5779d873ebc48c..42c2dcf17c529d 100644 --- a/lib/api/mcp/handlers/base.rb +++ b/lib/api/mcp/handlers/base.rb @@ -10,7 +10,7 @@ def initialize(params) @params = params end - def invoke(_) + def invoke raise NoMethodError end end diff --git a/lib/api/mcp/handlers/call_tool.rb b/lib/api/mcp/handlers/call_tool.rb new file mode 100644 index 00000000000000..fa614cf350e4b5 --- /dev/null +++ b/lib/api/mcp/handlers/call_tool.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module API + module Mcp + module Handlers + # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#calltoolrequest + class CallTool < Base + attr_reader :routes + + def initialize(routes) + @routes = routes + end + + def invoke(request, params) + route = find_route!(params[:name]) + status, body = perform_request(route, request, params) + process_response(status, body) + end + + private + + def find_route!(name) + route = routes.find { |route| route.app.route_setting(:mcp)[:name].to_s == name } + raise ArgumentError, 'name is unsupported' unless route + + route + end + + def perform_request(route, request, params) + args = params[:arguments].slice(*route.app.route_setting(:mcp)[:params]) + request.env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(args) + status, _, body = route.exec(request.env) + + [status, Array(body)[0]] + end + + def process_response(status, body) + if status >= 400 + parsed_response = Gitlab::Json.parse(body) + message = parsed_response['message'] || "HTTP #{status}" + ::Mcp::Tools::Response.error(message, parsed_response) + else + formatted_content = [{ type: 'text', text: body }] + ::Mcp::Tools::Response.success(formatted_content, []) + end + rescue JSON::ParserError => e + ::Mcp::Tools::Response.error('Invalid JSON response', { message: e.message }) + end + end + end + end +end diff --git a/lib/api/mcp/handlers/call_tool_request.rb b/lib/api/mcp/handlers/call_tool_request.rb index 8a02b4ccd84205..ec038ebf6961e4 100644 --- a/lib/api/mcp/handlers/call_tool_request.rb +++ b/lib/api/mcp/handlers/call_tool_request.rb @@ -10,17 +10,20 @@ def initialize(params, access_token) @access_token = access_token end - def invoke(request) + def invoke validate_params - route = ::API::API.routes.find { |route| route.app.route_setting(:mcp).try(:dig, :name).to_s == params[:name] } - raise ArgumentError, 'name is unsupported' unless route + tool_klass = ListToolsRequest::TOOLS[params[:name]] + raise ArgumentError, 'name is unsupported' unless tool_klass - request.env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(params[:arguments]) + tool = tool_klass.new(name: params[:name]) - _status, _headers, body = route.exec(request.env) - - body + begin + tool.execute(access_token, params[:arguments]) + rescue StandardError => e + # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#calltoolresult-iserror + ::Mcp::Tools::Response.error(e.message) + end end private diff --git a/lib/api/mcp/handlers/initialize_request.rb b/lib/api/mcp/handlers/initialize_request.rb index df2cc5af9da24c..e2eeed511381f5 100644 --- a/lib/api/mcp/handlers/initialize_request.rb +++ b/lib/api/mcp/handlers/initialize_request.rb @@ -5,7 +5,7 @@ module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#initializerequest class InitializeRequest < Base - def invoke(_) + def invoke { protocolVersion: '2025-06-18', capabilities: { diff --git a/lib/api/mcp/handlers/initialized_notification_request.rb b/lib/api/mcp/handlers/initialized_notification_request.rb index dda81e0f63d422..2e7ea69f6ab659 100644 --- a/lib/api/mcp/handlers/initialized_notification_request.rb +++ b/lib/api/mcp/handlers/initialized_notification_request.rb @@ -5,7 +5,7 @@ module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#initializednotification class InitializedNotificationRequest < Base - def invoke(_) + def invoke # JSON-RPC notifications are one-way messages nil end diff --git a/lib/api/mcp/handlers/list_tools.rb b/lib/api/mcp/handlers/list_tools.rb new file mode 100644 index 00000000000000..74bfc0da0bd890 --- /dev/null +++ b/lib/api/mcp/handlers/list_tools.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module API + module Mcp + module Handlers + # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#listtoolsrequest + class ListTools < Base + attr_reader :routes + + def initialize(routes) + @routes = routes + end + + def invoke + tools = routes.map do |route| + mcp_settings = route.app.route_setting(:mcp) + + { + name: mcp_settings[:name], + description: route.description, + inputSchema: build_input_schema(route, mcp_settings) + } + end + + { tools: tools } + end + + private + + def build_input_schema(route, mcp_settings) + required_fields = route.params.filter_map do |param, values| + param if values[:required] + end + + properties = route.params.slice(*mcp_settings[:params].map(&:to_s)).transform_values do |value| + { type: parse_type(value[:type]), description: value[:desc] } + end + + { + type: 'object', + properties: properties, + required: required_fields, + additionalProperties: false + } + end + + def parse_type(type) + array_str_match = type.match(/^\[(.*)\]$/) + if array_str_match + return array_str_match[1].split(", ")[0].downcase # return the first element from [String, Integer] types + end + + return 'boolean' if type == 'Grape::API::Boolean' + return 'array' if type.start_with?('Array') + + type.downcase + end + end + end + end +end diff --git a/lib/api/mcp/handlers/list_tools_request.rb b/lib/api/mcp/handlers/list_tools_request.rb index bf4008bc16c8e2..59c4bd70716bf6 100644 --- a/lib/api/mcp/handlers/list_tools_request.rb +++ b/lib/api/mcp/handlers/list_tools_request.rb @@ -21,33 +21,17 @@ class ListToolsRequest < Base }.freeze TOOLS = CUSTOM_TOOLS.merge(API_TOOLS) - def invoke(_) - tools = ::API::API.routes.select { |route| route.app.route_setting(:mcp) }.map do |route| - mcp_settings = route.app.route_setting(:mcp) - - { - name: mcp_settings[:name], - description: route.description, - inputSchema: { - type: 'object', - properties: route.params.slice(*mcp_settings[:params].keys).to_h do |key, value| - [key, { - type: mcp_settings[:params][key], - description: value[:desc] - }] - end, - required: route.params.filter_map do |param, values| - param if values[:required] - end, - additionalProperties: false - } - } - end - + def invoke { tools: tools } end + + private + + def tools + TOOLS.map { |tool_name, tool_klass| tool_klass.new(name: tool_name).to_h } + end end end end diff --git a/lib/api/mcp/server.rb b/lib/api/mcp/server.rb new file mode 100644 index 00000000000000..011f5be92ef427 --- /dev/null +++ b/lib/api/mcp/server.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module API + module Mcp + class Server < ::API::Base + include ::API::Helpers::HeadersHelpers + include APIGuard + + # JSON-RPC Specification + # See: https://www.jsonrpc.org/specification + JSONRPC_VERSION = '2.0' + + # JSON-RPC Error Codes + # See: https://www.jsonrpc.org/specification#error_object + JSONRPC_ERRORS = { + invalid_request: { + code: -32600, + message: 'Invalid Request' + }, + method_not_found: { + code: -32601, + message: 'Method not found' + }, + invalid_params: { + code: -32602, + message: 'Invalid params' + } + # NOTE: Parse error code -32700 is unsupported due to 400 Bad Request returned by Workhorse + }.freeze + + # JSON-RPC Supported Requests + # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#common-types + JSONRPC_METHOD_HANDLERS = { + 'initialize' => Handlers::InitializeRequest, + 'notifications/initialized' => Handlers::InitializedNotificationRequest + }.freeze + + feature_category :mcp_server + allow_access_with_scope :mcp + urgency :low + + before do + authenticate! + not_found! unless Feature.enabled?(:mcp_server, current_user) + forbidden! unless access_token&.scopes&.include?(Gitlab::Auth::MCP_SCOPE.to_s) + end + + helpers do + def invoke_basic_handler + method_name = params[:method] + handler_class = JSONRPC_METHOD_HANDLERS[method_name] || method_not_found!(method_name) + handler = handler_class.new(params[:params] || {}) + handler.invoke + end + + def method_not_found!(method_name) + # render error used to stop request and return early + render_structured_api_error!({ + jsonrpc: JSONRPC_VERSION, + error: JSONRPC_ERRORS[:method_not_found].merge({ data: { method: method_name } }), + id: params[:id] + }, 404) + end + + def format_jsonrpc_response(result) + if params[:id].nil? || result.nil? + # JSON-RPC server must not send JSON-RPC response for notifications + # See: https://modelcontextprotocol.io/specification/2025-06-18/basic/index#notifications + body false + else + { + jsonrpc: JSONRPC_VERSION, + result: result, + id: params[:id] + } + end + end + end + + # Model Context Protocol (MCP) specification + # See: https://modelcontextprotocol.io/specification/2025-06-18 + namespace :mcp_server do + namespace_setting :mcp_routes, ::API::API.routes.select { |route| route.app.route_setting(:mcp) } + params do + # JSON-RPC Request Object + # See: https://www.jsonrpc.org/specification#request_object + requires :jsonrpc, type: String, allow_blank: false, values: [JSONRPC_VERSION] + requires :method, type: String, allow_blank: false + optional :id, allow_blank: false # NOTE: JSON-RPC server must reply with same value and type for "id" member + optional :params, types: [Hash, Array] + end + + rescue_from Grape::Exceptions::ValidationErrors do |e| + render_structured_api_error!({ + jsonrpc: JSONRPC_VERSION, + error: JSONRPC_ERRORS[:invalid_request].merge({ data: { validations: e.full_messages } }), + id: nil + }, 400) + end + + rescue_from ArgumentError do |e| + render_structured_api_error!({ + jsonrpc: JSONRPC_VERSION, + error: JSONRPC_ERRORS[:invalid_params].merge({ data: { params: e.message } }), + id: nil + }, 400) + end + + # See: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server + post do + status :ok + + result = + case params[:method] + when 'tools/call' + Handlers::CallTool.new(namespace_setting(:mcp_routes)).invoke(request, params[:params]) + when 'tools/list' + Handlers::ListTools.new(namespace_setting(:mcp_routes)).invoke + else + invoke_basic_handler + end + + format_jsonrpc_response(result) + end + + # See: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + get do + status :not_implemented + end + end + end + end +end diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 91179d450c0b35..32104b142a6069 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -398,7 +398,7 @@ def batch_process_mergeability_checks(merge_requests) ] tags %w[merge_requests] end - route_setting :mcp, name: :get_merge_request, params: { 'id' => 'string', 'merge_request_iid' => 'integer' } + route_setting :mcp, name: :get_merge_request, params: [:id, :merge_request_iid] get ':id/merge_requests/:merge_request_iid', feature_category: :code_review_workflow, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) -- GitLab From 9a15f5b6cced25228ee7129067f86344dca36efc Mon Sep 17 00:00:00 2001 From: Igor Drozdov Date: Tue, 19 Aug 2025 16:27:44 +0200 Subject: [PATCH 3/6] Update openapi format for issues --- doc/api/openapi/openapi_v2.yaml | 3037 +++++++++++++++++++++++++++++-- 1 file changed, 2921 insertions(+), 116 deletions(-) diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index e97d1c4ed4bb64..b774db75ce882a 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -53,6 +53,8 @@ tags: description: Operations about cargo_packages - name: commits description: Operations about commits +- name: issues + description: Operations about issues - name: pages description: Operations about pages - name: pages_domains @@ -115,6 +117,8 @@ tags: description: Operations about imports - name: slack description: Operations about slacks +- name: issues_statistics + description: Operations about issues_statistics - name: topics description: Operations about topics - name: web_commits @@ -6522,6 +6526,601 @@ paths: tags: - invitations operationId: deleteApiV4GroupsIdInvitationsEmail + "/api/v4/groups/{id}/issues": + get: + description: Get a list of group issues + produces: + - application/json + parameters: + - in: path + name: id + description: The ID of a group + type: string + required: true + - in: query + name: with_labels_details + description: Return titles of labels and other details + type: boolean + default: false + required: false + - in: query + name: state + description: Return opened, closed, or all issues + type: string + default: all + enum: + - opened + - closed + - all + required: false + - in: query + name: closed_by_id + description: Return issues which were closed by the user with the given ID. + type: integer + format: int32 + required: false + - in: query + name: order_by + description: Return issues ordered by `created_at`, `due_date`, `label_priority`, + `milestone_due`, `popularity`, `priority`, `relative_position`, `title`, + or `updated_at` fields. + type: string + default: created_at + enum: + - created_at + - due_date + - label_priority + - milestone_due + - popularity + - priority + - relative_position + - title + - updated_at + - weight + required: false + - in: query + name: sort + description: Return issues sorted in `asc` or `desc` order. + type: string + default: desc + enum: + - asc + - desc + required: false + - in: query + name: due_date + description: 'Return issues that have no due date (`0`), or whose due date + is this week, this month, between two weeks ago and next month, or which + are overdue. Accepts: `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`, + `0`' + type: string + enum: + - '0' + - any + - today + - tomorrow + - overdue + - week + - month + - next_month_and_previous_two_weeks + - '' + required: false + - in: query + name: issue_type + description: 'The type of the issue. Accepts: issue, incident, test_case, + requirement, task, ticket' + type: string + enum: + - issue + - incident + - test_case + - requirement + - task + - ticket + required: false + - in: query + name: labels + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: milestone + description: Milestone title + type: string + required: false + - in: query + name: milestone_id + description: Return issues assigned to milestones with the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: iids + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: search + description: Search issues for text present in the title, description, or + any combination of these + type: string + required: false + - in: query + name: in + description: "`title`, `description`, or a string joining them with comma" + type: string + required: false + - in: query + name: author_id + description: Return issues which are authored by the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: author_username + description: Return issues which are authored by the user with the given username + type: string + required: false + - in: query + name: assignee_id + description: Return issues which are assigned to the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: assignee_username + description: Return issues which are assigned to the user with the given username + type: array + items: + type: string + required: false + - in: query + name: created_after + description: Return issues created after the specified time + type: string + format: date-time + required: false + - in: query + name: created_before + description: Return issues created before the specified time + type: string + format: date-time + required: false + - in: query + name: updated_after + description: Return issues updated after the specified time + type: string + format: date-time + required: false + - in: query + name: updated_before + description: Return issues updated before the specified time + type: string + format: date-time + required: false + - in: query + name: not[labels] + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: not[milestone] + description: Milestone title + type: string + required: false + - in: query + name: not[milestone_id] + description: Return issues assigned to milestones without the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: not[iids] + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: not[author_id] + description: Return issues which are not authored by the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[author_username] + description: Return issues which are not authored by the user with the given + username + type: string + required: false + - in: query + name: not[assignee_id] + description: Return issues which are not assigned to the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[assignee_username] + description: Return issues which are not assigned to the user with the given + username + type: array + items: + type: string + required: false + - in: query + name: not[weight] + description: Return issues without the specified weight + type: integer + format: int32 + required: false + - in: query + name: not[iteration_id] + description: Return issues which are not assigned to the iteration with the + given ID + type: integer + format: int32 + required: false + - in: query + name: not[iteration_title] + description: Return issues which are not assigned to the iteration with the + given title + type: string + required: false + - in: query + name: scope + description: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` + or `all`' + type: string + enum: + - created-by-me + - assigned-to-me + - created_by_me + - assigned_to_me + - all + required: false + - in: query + name: my_reaction_emoji + description: Return issues reacted by the authenticated user by the given + emoji + type: string + required: false + - in: query + name: confidential + description: Filter confidential or public issues + type: boolean + required: false + - in: query + name: weight + description: The weight of the issue + type: integer + format: int32 + required: false + - in: query + name: epic_id + description: The ID of an epic associated with the issues + type: integer + format: int32 + required: false + - in: query + name: health_status + description: 'The health status of the issue. Must be one of: on_track, needs_attention, + at_risk, none, any' + type: string + enum: + - on_track + - needs_attention + - at_risk + - none + - any + required: false + - in: query + name: iteration_id + description: Return issues which are assigned to the iteration with the given + ID + type: integer + format: int32 + required: false + - in: query + name: iteration_title + description: Return issues which are assigned to the iteration with the given + title + type: string + required: false + - in: query + name: page + description: Current page number + type: integer + format: int32 + default: 1 + required: false + example: 1 + - in: query + name: per_page + description: Number of items per page + type: integer + format: int32 + default: 20 + required: false + example: 20 + - in: query + name: non_archived + description: Return issues from non archived projects + type: boolean + default: true + required: false + responses: + '200': + description: Get a list of group issues + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - groups + operationId: getApiV4GroupsIdIssues + "/api/v4/groups/{id}/issues_statistics": + get: + description: Get statistics for the list of group issues + produces: + - application/json + parameters: + - in: path + name: id + description: The ID of a group + type: string + required: true + - in: query + name: labels + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: milestone + description: Milestone title + type: string + required: false + - in: query + name: milestone_id + description: Return issues assigned to milestones with the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: iids + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: search + description: Search issues for text present in the title, description, or + any combination of these + type: string + required: false + - in: query + name: in + description: "`title`, `description`, or a string joining them with comma" + type: string + required: false + - in: query + name: author_id + description: Return issues which are authored by the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: author_username + description: Return issues which are authored by the user with the given username + type: string + required: false + - in: query + name: assignee_id + description: Return issues which are assigned to the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: assignee_username + description: Return issues which are assigned to the user with the given username + type: array + items: + type: string + required: false + - in: query + name: created_after + description: Return issues created after the specified time + type: string + format: date-time + required: false + - in: query + name: created_before + description: Return issues created before the specified time + type: string + format: date-time + required: false + - in: query + name: updated_after + description: Return issues updated after the specified time + type: string + format: date-time + required: false + - in: query + name: updated_before + description: Return issues updated before the specified time + type: string + format: date-time + required: false + - in: query + name: not[labels] + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: not[milestone] + description: Milestone title + type: string + required: false + - in: query + name: not[milestone_id] + description: Return issues assigned to milestones without the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: not[iids] + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: not[author_id] + description: Return issues which are not authored by the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[author_username] + description: Return issues which are not authored by the user with the given + username + type: string + required: false + - in: query + name: not[assignee_id] + description: Return issues which are not assigned to the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[assignee_username] + description: Return issues which are not assigned to the user with the given + username + type: array + items: + type: string + required: false + - in: query + name: not[weight] + description: Return issues without the specified weight + type: integer + format: int32 + required: false + - in: query + name: not[iteration_id] + description: Return issues which are not assigned to the iteration with the + given ID + type: integer + format: int32 + required: false + - in: query + name: not[iteration_title] + description: Return issues which are not assigned to the iteration with the + given title + type: string + required: false + - in: query + name: scope + description: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` + or `all`' + type: string + enum: + - created-by-me + - assigned-to-me + - created_by_me + - assigned_to_me + - all + required: false + - in: query + name: my_reaction_emoji + description: Return issues reacted by the authenticated user by the given + emoji + type: string + required: false + - in: query + name: confidential + description: Filter confidential or public issues + type: boolean + required: false + - in: query + name: weight + description: The weight of the issue + type: integer + format: int32 + required: false + - in: query + name: epic_id + description: The ID of an epic associated with the issues + type: integer + format: int32 + required: false + - in: query + name: health_status + description: 'The health status of the issue. Must be one of: on_track, needs_attention, + at_risk, none, any' + type: string + enum: + - on_track + - needs_attention + - at_risk + - none + - any + required: false + - in: query + name: iteration_id + description: Return issues which are assigned to the iteration with the given + ID + type: integer + format: int32 + required: false + - in: query + name: iteration_title + description: Return issues which are assigned to the iteration with the given + title + type: string + required: false + responses: + '200': + description: Get statistics for the list of group issues + tags: + - groups + operationId: getApiV4GroupsIdIssuesStatistics "/api/v4/groups/{id}/uploads": get: description: Get the list of uploads of a group @@ -23905,6 +24504,1210 @@ paths: tags: - issue_links operationId: deleteApiV4ProjectsIdIssuesIssueIidLinksIssueLinkId + "/api/v4/projects/{id}/issues/{issue_iid}/time_estimate": + post: + summary: Set a time estimate for a issue + description: Sets an estimated time of work for this issue. + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of the issue. + type: integer + format: int32 + required: true + - name: postApiV4ProjectsIdIssuesIssueIidTimeEstimate + in: body + required: true + schema: + "$ref": "#/definitions/postApiV4ProjectsIdIssuesIssueIidTimeEstimate" + responses: + '201': + description: Set a time estimate for a issue + schema: + "$ref": "#/definitions/API_Entities_IssuableTimeStats" + '401': + description: Unauthorized + '400': + description: Bad request + '404': + description: Not found + tags: + - issues + operationId: postApiV4ProjectsIdIssuesIssueIidTimeEstimate + "/api/v4/projects/{id}/issues/{issue_iid}/reset_time_estimate": + post: + summary: Reset the time estimate for a project issue + description: Resets the estimated time for this issue to 0 seconds. + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of the issue. + type: integer + format: int32 + required: true + responses: + '201': + description: Reset the time estimate for a project issue + schema: + "$ref": "#/definitions/API_Entities_IssuableTimeStats" + '401': + description: Unauthorized + '404': + description: Not found + tags: + - issues + operationId: postApiV4ProjectsIdIssuesIssueIidResetTimeEstimate + "/api/v4/projects/{id}/issues/{issue_iid}/add_spent_time": + post: + summary: Add spent time for a issue + description: Adds spent time for this issue. + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of the issue. + type: integer + format: int32 + required: true + - name: postApiV4ProjectsIdIssuesIssueIidAddSpentTime + in: body + required: true + schema: + "$ref": "#/definitions/postApiV4ProjectsIdIssuesIssueIidAddSpentTime" + responses: + '201': + description: Add spent time for a issue + schema: + "$ref": "#/definitions/API_Entities_IssuableTimeStats" + '401': + description: Unauthorized + '404': + description: Not found + tags: + - issues + operationId: postApiV4ProjectsIdIssuesIssueIidAddSpentTime + "/api/v4/projects/{id}/issues/{issue_iid}/reset_spent_time": + post: + summary: Reset spent time for a issue + description: Resets the total spent time for this issue to 0 seconds. + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of the issue + type: integer + format: int32 + required: true + responses: + '201': + description: Reset spent time for a issue + schema: + "$ref": "#/definitions/API_Entities_IssuableTimeStats" + '401': + description: Unauthorized + '404': + description: Not found + tags: + - issues + operationId: postApiV4ProjectsIdIssuesIssueIidResetSpentTime + "/api/v4/projects/{id}/issues/{issue_iid}/time_stats": + get: + summary: Get time tracking stats + description: Get time tracking stats + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of the issue + type: integer + format: int32 + required: true + responses: + '200': + description: Get time tracking stats + schema: + "$ref": "#/definitions/API_Entities_IssuableTimeStats" + '401': + description: Unauthorized + '404': + description: Not found + tags: + - issues + operationId: getApiV4ProjectsIdIssuesIssueIidTimeStats + "/api/v4/projects/{id}/issues": + get: + description: Get a list of project issues + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: query + name: with_labels_details + description: Return titles of labels and other details + type: boolean + default: false + required: false + - in: query + name: state + description: Return opened, closed, or all issues + type: string + default: all + enum: + - opened + - closed + - all + required: false + - in: query + name: closed_by_id + description: Return issues which were closed by the user with the given ID. + type: integer + format: int32 + required: false + - in: query + name: order_by + description: Return issues ordered by `created_at`, `due_date`, `label_priority`, + `milestone_due`, `popularity`, `priority`, `relative_position`, `title`, + or `updated_at` fields. + type: string + default: created_at + enum: + - created_at + - due_date + - label_priority + - milestone_due + - popularity + - priority + - relative_position + - title + - updated_at + - weight + required: false + - in: query + name: sort + description: Return issues sorted in `asc` or `desc` order. + type: string + default: desc + enum: + - asc + - desc + required: false + - in: query + name: due_date + description: 'Return issues that have no due date (`0`), or whose due date + is this week, this month, between two weeks ago and next month, or which + are overdue. Accepts: `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`, + `0`' + type: string + enum: + - '0' + - any + - today + - tomorrow + - overdue + - week + - month + - next_month_and_previous_two_weeks + - '' + required: false + - in: query + name: issue_type + description: 'The type of the issue. Accepts: issue, incident, test_case, + requirement, task, ticket' + type: string + enum: + - issue + - incident + - test_case + - requirement + - task + - ticket + required: false + - in: query + name: labels + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: milestone + description: Milestone title + type: string + required: false + - in: query + name: milestone_id + description: Return issues assigned to milestones with the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: iids + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: search + description: Search issues for text present in the title, description, or + any combination of these + type: string + required: false + - in: query + name: in + description: "`title`, `description`, or a string joining them with comma" + type: string + required: false + - in: query + name: author_id + description: Return issues which are authored by the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: author_username + description: Return issues which are authored by the user with the given username + type: string + required: false + - in: query + name: assignee_id + description: Return issues which are assigned to the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: assignee_username + description: Return issues which are assigned to the user with the given username + type: array + items: + type: string + required: false + - in: query + name: created_after + description: Return issues created after the specified time + type: string + format: date-time + required: false + - in: query + name: created_before + description: Return issues created before the specified time + type: string + format: date-time + required: false + - in: query + name: updated_after + description: Return issues updated after the specified time + type: string + format: date-time + required: false + - in: query + name: updated_before + description: Return issues updated before the specified time + type: string + format: date-time + required: false + - in: query + name: not[labels] + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: not[milestone] + description: Milestone title + type: string + required: false + - in: query + name: not[milestone_id] + description: Return issues assigned to milestones without the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: not[iids] + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: not[author_id] + description: Return issues which are not authored by the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[author_username] + description: Return issues which are not authored by the user with the given + username + type: string + required: false + - in: query + name: not[assignee_id] + description: Return issues which are not assigned to the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[assignee_username] + description: Return issues which are not assigned to the user with the given + username + type: array + items: + type: string + required: false + - in: query + name: not[weight] + description: Return issues without the specified weight + type: integer + format: int32 + required: false + - in: query + name: not[iteration_id] + description: Return issues which are not assigned to the iteration with the + given ID + type: integer + format: int32 + required: false + - in: query + name: not[iteration_title] + description: Return issues which are not assigned to the iteration with the + given title + type: string + required: false + - in: query + name: scope + description: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` + or `all`' + type: string + enum: + - created-by-me + - assigned-to-me + - created_by_me + - assigned_to_me + - all + required: false + - in: query + name: my_reaction_emoji + description: Return issues reacted by the authenticated user by the given + emoji + type: string + required: false + - in: query + name: confidential + description: Filter confidential or public issues + type: boolean + required: false + - in: query + name: weight + description: The weight of the issue + type: integer + format: int32 + required: false + - in: query + name: epic_id + description: The ID of an epic associated with the issues + type: integer + format: int32 + required: false + - in: query + name: health_status + description: 'The health status of the issue. Must be one of: on_track, needs_attention, + at_risk, none, any' + type: string + enum: + - on_track + - needs_attention + - at_risk + - none + - any + required: false + - in: query + name: iteration_id + description: Return issues which are assigned to the iteration with the given + ID + type: integer + format: int32 + required: false + - in: query + name: iteration_title + description: Return issues which are assigned to the iteration with the given + title + type: string + required: false + - in: query + name: page + description: Current page number + type: integer + format: int32 + default: 1 + required: false + example: 1 + - in: query + name: per_page + description: Number of items per page + type: integer + format: int32 + default: 20 + required: false + example: 20 + - in: query + name: cursor + description: Cursor for obtaining the next set of records + type: string + required: false + responses: + '200': + description: Get a list of project issues + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - projects + operationId: getApiV4ProjectsIdIssues + post: + description: Create a new project issue + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - name: postApiV4ProjectsIdIssues + in: body + required: true + schema: + "$ref": "#/definitions/postApiV4ProjectsIdIssues" + responses: + '201': + description: Create a new project issue + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - projects + operationId: postApiV4ProjectsIdIssues + "/api/v4/projects/{id}/issues_statistics": + get: + description: Get statistics for the list of project issues + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: query + name: labels + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: milestone + description: Milestone title + type: string + required: false + - in: query + name: milestone_id + description: Return issues assigned to milestones with the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: iids + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: search + description: Search issues for text present in the title, description, or + any combination of these + type: string + required: false + - in: query + name: in + description: "`title`, `description`, or a string joining them with comma" + type: string + required: false + - in: query + name: author_id + description: Return issues which are authored by the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: author_username + description: Return issues which are authored by the user with the given username + type: string + required: false + - in: query + name: assignee_id + description: Return issues which are assigned to the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: assignee_username + description: Return issues which are assigned to the user with the given username + type: array + items: + type: string + required: false + - in: query + name: created_after + description: Return issues created after the specified time + type: string + format: date-time + required: false + - in: query + name: created_before + description: Return issues created before the specified time + type: string + format: date-time + required: false + - in: query + name: updated_after + description: Return issues updated after the specified time + type: string + format: date-time + required: false + - in: query + name: updated_before + description: Return issues updated before the specified time + type: string + format: date-time + required: false + - in: query + name: not[labels] + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: not[milestone] + description: Milestone title + type: string + required: false + - in: query + name: not[milestone_id] + description: Return issues assigned to milestones without the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: not[iids] + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: not[author_id] + description: Return issues which are not authored by the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[author_username] + description: Return issues which are not authored by the user with the given + username + type: string + required: false + - in: query + name: not[assignee_id] + description: Return issues which are not assigned to the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[assignee_username] + description: Return issues which are not assigned to the user with the given + username + type: array + items: + type: string + required: false + - in: query + name: not[weight] + description: Return issues without the specified weight + type: integer + format: int32 + required: false + - in: query + name: not[iteration_id] + description: Return issues which are not assigned to the iteration with the + given ID + type: integer + format: int32 + required: false + - in: query + name: not[iteration_title] + description: Return issues which are not assigned to the iteration with the + given title + type: string + required: false + - in: query + name: scope + description: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` + or `all`' + type: string + enum: + - created-by-me + - assigned-to-me + - created_by_me + - assigned_to_me + - all + required: false + - in: query + name: my_reaction_emoji + description: Return issues reacted by the authenticated user by the given + emoji + type: string + required: false + - in: query + name: confidential + description: Filter confidential or public issues + type: boolean + required: false + - in: query + name: weight + description: The weight of the issue + type: integer + format: int32 + required: false + - in: query + name: epic_id + description: The ID of an epic associated with the issues + type: integer + format: int32 + required: false + - in: query + name: health_status + description: 'The health status of the issue. Must be one of: on_track, needs_attention, + at_risk, none, any' + type: string + enum: + - on_track + - needs_attention + - at_risk + - none + - any + required: false + - in: query + name: iteration_id + description: Return issues which are assigned to the iteration with the given + ID + type: integer + format: int32 + required: false + - in: query + name: iteration_title + description: Return issues which are assigned to the iteration with the given + title + type: string + required: false + responses: + '200': + description: Get statistics for the list of project issues + tags: + - projects + operationId: getApiV4ProjectsIdIssuesStatistics + "/api/v4/projects/{id}/issues/{issue_iid}": + get: + description: Get a single project issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + responses: + '200': + description: Get a single project issue + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - projects + operationId: getApiV4ProjectsIdIssuesIssueIid + put: + description: Update an existing issue + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + - name: putApiV4ProjectsIdIssuesIssueIid + in: body + required: true + schema: + "$ref": "#/definitions/putApiV4ProjectsIdIssuesIssueIid" + responses: + '200': + description: Update an existing issue + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - projects + operationId: putApiV4ProjectsIdIssuesIssueIid + delete: + description: Delete a project issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + responses: + '204': + description: Delete a project issue + tags: + - projects + operationId: deleteApiV4ProjectsIdIssuesIssueIid + "/api/v4/projects/{id}/issues/{issue_iid}/reorder": + put: + description: Reorder an existing issue + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + - name: putApiV4ProjectsIdIssuesIssueIidReorder + in: body + required: true + schema: + "$ref": "#/definitions/putApiV4ProjectsIdIssuesIssueIidReorder" + responses: + '200': + description: Reorder an existing issue + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - projects + operationId: putApiV4ProjectsIdIssuesIssueIidReorder + "/api/v4/projects/{id}/issues/{issue_iid}/move": + post: + description: Move an existing issue + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + - name: postApiV4ProjectsIdIssuesIssueIidMove + in: body + required: true + schema: + "$ref": "#/definitions/postApiV4ProjectsIdIssuesIssueIidMove" + responses: + '201': + description: Move an existing issue + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - projects + operationId: postApiV4ProjectsIdIssuesIssueIidMove + "/api/v4/projects/{id}/issues/{issue_iid}/clone": + post: + description: Clone an existing issue + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + - name: postApiV4ProjectsIdIssuesIssueIidClone + in: body + required: true + schema: + "$ref": "#/definitions/postApiV4ProjectsIdIssuesIssueIidClone" + responses: + '201': + description: Clone an existing issue + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - projects + operationId: postApiV4ProjectsIdIssuesIssueIidClone + "/api/v4/projects/{id}/issues/{issue_iid}/related_merge_requests": + get: + description: List merge requests that are related to the issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + responses: + '200': + description: List merge requests that are related to the issue + schema: + "$ref": "#/definitions/API_Entities_MergeRequestBasic" + tags: + - projects + operationId: getApiV4ProjectsIdIssuesIssueIidRelatedMergeRequests + "/api/v4/projects/{id}/issues/{issue_iid}/closed_by": + get: + description: List merge requests closing issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + responses: + '200': + description: List merge requests closing issue + schema: + "$ref": "#/definitions/API_Entities_MergeRequestBasic" + tags: + - projects + operationId: getApiV4ProjectsIdIssuesIssueIidClosedBy + "/api/v4/projects/{id}/issues/{issue_iid}/participants": + get: + description: List participants for an issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + responses: + '200': + description: List participants for an issue + schema: + "$ref": "#/definitions/API_Entities_UserBasic" + tags: + - projects + operationId: getApiV4ProjectsIdIssuesIssueIidParticipants + "/api/v4/projects/{id}/issues/{issue_iid}/user_agent_detail": + get: + description: Get the user agent details for an issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + description: The internal ID of a project issue + type: integer + format: int32 + required: true + responses: + '200': + description: Get the user agent details for an issue + schema: + "$ref": "#/definitions/API_Entities_UserAgentDetail" + tags: + - projects + operationId: getApiV4ProjectsIdIssuesIssueIidUserAgentDetail + "/api/v4/projects/{id}/issues/{issue_iid}/metric_images/authorize": + post: + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + type: integer + format: int32 + required: true + responses: + '201': + description: created Authorize + tags: + - projects + operationId: postApiV4ProjectsIdIssuesIssueIidMetricImagesAuthorize + "/api/v4/projects/{id}/issues/{issue_iid}/metric_images": + post: + description: Upload a metric image for an issue + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + type: integer + format: int32 + required: true + - name: postApiV4ProjectsIdIssuesIssueIidMetricImages + in: body + required: true + schema: + "$ref": "#/definitions/postApiV4ProjectsIdIssuesIssueIidMetricImages" + responses: + '201': + description: Upload a metric image for an issue + schema: + "$ref": "#/definitions/EE_API_Entities_IssuableMetricImage" + tags: + - projects + operationId: postApiV4ProjectsIdIssuesIssueIidMetricImages + get: + description: Metric Images for issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: issue_iid + type: integer + format: int32 + required: true + responses: + '200': + description: Metric Images for issue + tags: + - projects + operationId: getApiV4ProjectsIdIssuesIssueIidMetricImages + "/api/v4/projects/{id}/issues/{issue_iid}/metric_images/{metric_image_id}": + put: + description: Update a metric image for an issue + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: metric_image_id + description: The ID of metric image + type: integer + format: int32 + required: true + - in: path + name: issue_iid + type: integer + format: int32 + required: true + - name: putApiV4ProjectsIdIssuesIssueIidMetricImagesMetricImageId + in: body + required: true + schema: + "$ref": "#/definitions/putApiV4ProjectsIdIssuesIssueIidMetricImagesMetricImageId" + responses: + '200': + description: Update a metric image for an issue + schema: + "$ref": "#/definitions/EE_API_Entities_IssuableMetricImage" + tags: + - projects + operationId: putApiV4ProjectsIdIssuesIssueIidMetricImagesMetricImageId + delete: + description: Remove a metric image for an issue + produces: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project + type: string + required: true + - in: path + name: metric_image_id + description: The ID of metric image + type: integer + format: int32 + required: true + - in: path + name: issue_iid + type: integer + format: int32 + required: true + responses: + '200': + description: Remove a metric image for an issue + schema: + "$ref": "#/definitions/EE_API_Entities_IssuableMetricImage" + tags: + - projects + operationId: deleteApiV4ProjectsIdIssuesIssueIidMetricImagesMetricImageId "/api/v4/projects/{id}/ci/lint": get: summary: Validates a CI YAML configuration with a namespace @@ -41419,6 +43222,371 @@ paths: tags: - jira_connect_subscriptions operationId: postApiV4IntegrationsJiraConnectSubscriptions + "/api/v4/issues": + get: + description: Get currently authenticated user's issues + produces: + - application/json + parameters: + - in: query + name: with_labels_details + description: Return titles of labels and other details + type: boolean + default: false + required: false + - in: query + name: state + description: Return opened, closed, or all issues + type: string + default: all + enum: + - opened + - closed + - all + required: false + - in: query + name: closed_by_id + description: Return issues which were closed by the user with the given ID. + type: integer + format: int32 + required: false + - in: query + name: order_by + description: Return issues ordered by `created_at`, `due_date`, `label_priority`, + `milestone_due`, `popularity`, `priority`, `relative_position`, `title`, + or `updated_at` fields. + type: string + default: created_at + enum: + - created_at + - due_date + - label_priority + - milestone_due + - popularity + - priority + - relative_position + - title + - updated_at + - weight + required: false + - in: query + name: sort + description: Return issues sorted in `asc` or `desc` order. + type: string + default: desc + enum: + - asc + - desc + required: false + - in: query + name: due_date + description: 'Return issues that have no due date (`0`), or whose due date + is this week, this month, between two weeks ago and next month, or which + are overdue. Accepts: `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`, + `0`' + type: string + enum: + - '0' + - any + - today + - tomorrow + - overdue + - week + - month + - next_month_and_previous_two_weeks + - '' + required: false + - in: query + name: issue_type + description: 'The type of the issue. Accepts: issue, incident, test_case, + requirement, task, ticket' + type: string + enum: + - issue + - incident + - test_case + - requirement + - task + - ticket + required: false + - in: query + name: labels + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: milestone + description: Milestone title + type: string + required: false + - in: query + name: milestone_id + description: Return issues assigned to milestones with the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: iids + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: search + description: Search issues for text present in the title, description, or + any combination of these + type: string + required: false + - in: query + name: in + description: "`title`, `description`, or a string joining them with comma" + type: string + required: false + - in: query + name: author_id + description: Return issues which are authored by the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: author_username + description: Return issues which are authored by the user with the given username + type: string + required: false + - in: query + name: assignee_id + description: Return issues which are assigned to the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: assignee_username + description: Return issues which are assigned to the user with the given username + type: array + items: + type: string + required: false + - in: query + name: created_after + description: Return issues created after the specified time + type: string + format: date-time + required: false + - in: query + name: created_before + description: Return issues created before the specified time + type: string + format: date-time + required: false + - in: query + name: updated_after + description: Return issues updated after the specified time + type: string + format: date-time + required: false + - in: query + name: updated_before + description: Return issues updated before the specified time + type: string + format: date-time + required: false + - in: query + name: not[labels] + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: not[milestone] + description: Milestone title + type: string + required: false + - in: query + name: not[milestone_id] + description: Return issues assigned to milestones without the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: not[iids] + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: not[author_id] + description: Return issues which are not authored by the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[author_username] + description: Return issues which are not authored by the user with the given + username + type: string + required: false + - in: query + name: not[assignee_id] + description: Return issues which are not assigned to the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[assignee_username] + description: Return issues which are not assigned to the user with the given + username + type: array + items: + type: string + required: false + - in: query + name: not[weight] + description: Return issues without the specified weight + type: integer + format: int32 + required: false + - in: query + name: not[iteration_id] + description: Return issues which are not assigned to the iteration with the + given ID + type: integer + format: int32 + required: false + - in: query + name: not[iteration_title] + description: Return issues which are not assigned to the iteration with the + given title + type: string + required: false + - in: query + name: scope + description: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` + or `all`' + type: string + default: created_by_me + enum: + - created-by-me + - assigned-to-me + - created_by_me + - assigned_to_me + - all + required: false + - in: query + name: my_reaction_emoji + description: Return issues reacted by the authenticated user by the given + emoji + type: string + required: false + - in: query + name: confidential + description: Filter confidential or public issues + type: boolean + required: false + - in: query + name: weight + description: The weight of the issue + type: integer + format: int32 + required: false + - in: query + name: epic_id + description: The ID of an epic associated with the issues + type: integer + format: int32 + required: false + - in: query + name: health_status + description: 'The health status of the issue. Must be one of: on_track, needs_attention, + at_risk, none, any' + type: string + enum: + - on_track + - needs_attention + - at_risk + - none + - any + required: false + - in: query + name: iteration_id + description: Return issues which are assigned to the iteration with the given + ID + type: integer + format: int32 + required: false + - in: query + name: iteration_title + description: Return issues which are assigned to the iteration with the given + title + type: string + required: false + - in: query + name: page + description: Current page number + type: integer + format: int32 + default: 1 + required: false + example: 1 + - in: query + name: per_page + description: Number of items per page + type: integer + format: int32 + default: 20 + required: false + example: 20 + - in: query + name: non_archived + description: Return issues from non archived projects + type: boolean + default: true + required: false + responses: + '200': + description: Get currently authenticated user's issues + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - issues + operationId: getApiV4Issues + "/api/v4/issues/{id}": + get: + description: Get specified issue (admin only) + produces: + - application/json + parameters: + - in: path + name: id + description: The ID of the Issue + type: string + required: true + responses: + '200': + description: Get specified issue (admin only) + schema: + "$ref": "#/definitions/API_Entities_Issue" + tags: + - issues + operationId: getApiV4IssuesId "/api/v4/keys/{id}": get: summary: Get single ssh key by id. Only available to admin users @@ -43965,6 +46133,245 @@ paths: tags: - slack operationId: postApiV4SlackTrigger + "/api/v4/issues_statistics": + get: + description: Get currently authenticated user's issues statistics + produces: + - application/json + parameters: + - in: query + name: labels + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: milestone + description: Milestone title + type: string + required: false + - in: query + name: milestone_id + description: Return issues assigned to milestones with the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: iids + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: search + description: Search issues for text present in the title, description, or + any combination of these + type: string + required: false + - in: query + name: in + description: "`title`, `description`, or a string joining them with comma" + type: string + required: false + - in: query + name: author_id + description: Return issues which are authored by the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: author_username + description: Return issues which are authored by the user with the given username + type: string + required: false + - in: query + name: assignee_id + description: Return issues which are assigned to the user with the given ID + type: integer + format: int32 + required: false + - in: query + name: assignee_username + description: Return issues which are assigned to the user with the given username + type: array + items: + type: string + required: false + - in: query + name: created_after + description: Return issues created after the specified time + type: string + format: date-time + required: false + - in: query + name: created_before + description: Return issues created before the specified time + type: string + format: date-time + required: false + - in: query + name: updated_after + description: Return issues updated after the specified time + type: string + format: date-time + required: false + - in: query + name: updated_before + description: Return issues updated before the specified time + type: string + format: date-time + required: false + - in: query + name: not[labels] + description: Comma-separated list of label names + type: array + items: + type: string + required: false + - in: query + name: not[milestone] + description: Milestone title + type: string + required: false + - in: query + name: not[milestone_id] + description: Return issues assigned to milestones without the specified timebox + value ("Any", "None", "Upcoming" or "Started") + type: string + enum: + - Any + - None + - Upcoming + - Started + required: false + - in: query + name: not[iids] + description: The IID array of issues + type: array + items: + type: integer + format: int32 + required: false + - in: query + name: not[author_id] + description: Return issues which are not authored by the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[author_username] + description: Return issues which are not authored by the user with the given + username + type: string + required: false + - in: query + name: not[assignee_id] + description: Return issues which are not assigned to the user with the given + ID + type: integer + format: int32 + required: false + - in: query + name: not[assignee_username] + description: Return issues which are not assigned to the user with the given + username + type: array + items: + type: string + required: false + - in: query + name: not[weight] + description: Return issues without the specified weight + type: integer + format: int32 + required: false + - in: query + name: not[iteration_id] + description: Return issues which are not assigned to the iteration with the + given ID + type: integer + format: int32 + required: false + - in: query + name: not[iteration_title] + description: Return issues which are not assigned to the iteration with the + given title + type: string + required: false + - in: query + name: scope + description: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` + or `all`' + type: string + default: created_by_me + enum: + - created_by_me + - assigned_to_me + - all + required: false + - in: query + name: my_reaction_emoji + description: Return issues reacted by the authenticated user by the given + emoji + type: string + required: false + - in: query + name: confidential + description: Filter confidential or public issues + type: boolean + required: false + - in: query + name: weight + description: The weight of the issue + type: integer + format: int32 + required: false + - in: query + name: epic_id + description: The ID of an epic associated with the issues + type: integer + format: int32 + required: false + - in: query + name: health_status + description: 'The health status of the issue. Must be one of: on_track, needs_attention, + at_risk, none, any' + type: string + enum: + - on_track + - needs_attention + - at_risk + - none + - any + required: false + - in: query + name: iteration_id + description: Return issues which are assigned to the iteration with the given + ID + type: integer + format: int32 + required: false + - in: query + name: iteration_title + description: Return issues which are assigned to the iteration with the given + title + type: string + required: false + responses: + '200': + description: Get currently authenticated user's issues statistics + tags: + - issues_statistics + operationId: getApiV4IssuesStatistics "/api/v4/metadata": get: summary: Retrieve metadata information for this GitLab instance @@ -48941,6 +51348,237 @@ definitions: format: int32 description: The ID of a member role for the invited user description: Updates a group or project invitation. + API_Entities_Issue: + type: object + properties: + id: + type: integer + format: int32 + example: 84 + iid: + type: integer + format: int32 + example: 14 + project_id: + type: integer + format: int32 + example: 4 + title: + type: string + example: Impedit et ut et dolores vero provident ullam est + description: + type: string + example: Repellendus impedit et vel velit dignissimos. + state: + type: string + example: closed + created_at: + type: string + format: date-time + example: '2022-08-17T12:46:35.053Z' + updated_at: + type: string + format: date-time + example: '2022-11-14T17:22:01.470Z' + closed_at: + type: string + format: date-time + example: '2022-11-15T08:30:55.232Z' + closed_by: + "$ref": "#/definitions/API_Entities_UserBasic" + labels: + type: array + items: + type: string + example: bug + milestone: + "$ref": "#/definitions/API_Entities_Milestone" + assignees: + "$ref": "#/definitions/API_Entities_UserBasic" + author: + "$ref": "#/definitions/API_Entities_UserBasic" + type: + type: string + example: ISSUE + description: One of ["ISSUE", "INCIDENT", "TEST_CASE", "REQUIREMENT", "TASK", + "TICKET"] + assignee: + "$ref": "#/definitions/API_Entities_UserBasic" + user_notes_count: + type: string + merge_requests_count: + type: string + upvotes: + type: string + downvotes: + type: string + due_date: + type: string + format: date + example: '2022-11-20' + confidential: + type: boolean + discussion_locked: + type: boolean + issue_type: + type: string + example: issue + web_url: + type: string + example: http://example.com/example/example/issues/14 + time_stats: + "$ref": "#/definitions/API_Entities_IssuableTimeStats" + task_completion_status: + type: string + weight: + type: string + blocking_issues_count: + type: string + has_tasks: + type: string + task_status: + type: string + _links: + type: object + properties: + self: + type: string + notes: + type: string + award_emoji: + type: string + project: + type: string + closed_as_duplicate_of: + type: string + references: + "$ref": "#/definitions/API_Entities_IssuableReferences" + severity: + type: string + description: One of ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"] + subscribed: + type: string + moved_to_id: + type: string + imported: + type: string + imported_from: + type: string + example: github + service_desk_reply_to: + type: string + epic_iid: + type: string + epic: + "$ref": "#/definitions/EpicBaseEntity" + iteration: + "$ref": "#/definitions/API_Entities_Iteration" + health_status: + type: string + description: API_Entities_Issue model + API_Entities_Milestone: + type: object + properties: + id: + type: string + iid: + type: string + project_id: + type: string + group_id: + type: string + title: + type: string + description: + type: string + state: + type: string + created_at: + type: string + updated_at: + type: string + due_date: + type: string + start_date: + type: string + expired: + type: string + web_url: + type: string + API_Entities_IssuableTimeStats: + type: object + properties: + time_estimate: + type: integer + format: int32 + example: 12600 + total_time_spent: + type: integer + format: int32 + example: 3600 + human_time_estimate: + type: string + example: 3h 30m + human_total_time_spent: + type: string + example: 1h + description: API_Entities_IssuableTimeStats model + API_Entities_IssuableReferences: + type: object + properties: + short: + type: string + example: "&6" + relative: + type: string + example: "&6" + full: + type: string + example: test&6 + EpicBaseEntity: + type: object + properties: + id: + type: string + iid: + type: string + title: + type: string + url: + type: string + group_id: + type: string + human_readable_end_date: + type: string + human_readable_timestamp: + type: string + API_Entities_Iteration: + type: object + properties: + id: + type: string + iid: + type: string + sequence: + type: string + group_id: + type: string + title: + type: string + description: + type: string + state: + type: string + created_at: + type: string + updated_at: + type: string + start_date: + type: string + due_date: + type: string + web_url: + type: string API_Entities_MarkdownUploadAdmin: type: object properties: @@ -49335,65 +51973,6 @@ definitions: approvals_before_merge: type: string description: API_Entities_MergeRequestBasic model - API_Entities_Milestone: - type: object - properties: - id: - type: string - iid: - type: string - project_id: - type: string - group_id: - type: string - title: - type: string - description: - type: string - state: - type: string - created_at: - type: string - updated_at: - type: string - due_date: - type: string - start_date: - type: string - expired: - type: string - web_url: - type: string - API_Entities_IssuableReferences: - type: object - properties: - short: - type: string - example: "&6" - relative: - type: string - example: "&6" - full: - type: string - example: test&6 - API_Entities_IssuableTimeStats: - type: object - properties: - time_estimate: - type: integer - format: int32 - example: 12600 - total_time_spent: - type: integer - format: int32 - example: 3600 - human_time_estimate: - type: string - example: 3h 30m - human_total_time_spent: - type: string - example: 1h - description: API_Entities_IssuableTimeStats model API_Entities_NpmPackageTag: type: object properties: @@ -57448,50 +60027,6 @@ definitions: link_updated_at: type: string description: API_Entities_RelatedIssue model - EpicBaseEntity: - type: object - properties: - id: - type: string - iid: - type: string - title: - type: string - url: - type: string - group_id: - type: string - human_readable_end_date: - type: string - human_readable_timestamp: - type: string - API_Entities_Iteration: - type: object - properties: - id: - type: string - iid: - type: string - sequence: - type: string - group_id: - type: string - title: - type: string - description: - type: string - state: - type: string - created_at: - type: string - updated_at: - type: string - start_date: - type: string - due_date: - type: string - web_url: - type: string postApiV4ProjectsIdIssuesIssueIidLinks: type: object properties: @@ -57609,6 +60144,289 @@ definitions: type: string blocking_issues_count: type: string + postApiV4ProjectsIdIssuesIssueIidTimeEstimate: + type: object + properties: + duration: + type: string + description: The duration in human format. + example: 3h30m + required: + - duration + description: Set a time estimate for a issue + postApiV4ProjectsIdIssuesIssueIidAddSpentTime: + type: object + properties: + duration: + type: string + description: The duration in human format. + required: + - duration + description: Add spent time for a issue + postApiV4ProjectsIdIssues: + type: object + properties: + title: + type: string + description: The title of an issue + created_at: + type: string + format: date-time + description: Date time when the issue was created. Available only for admins + and project owners. + merge_request_to_resolve_discussions_of: + type: integer + format: int32 + description: The IID of a merge request for which to resolve discussions + discussion_to_resolve: + type: string + description: The ID of a discussion to resolve, also pass `merge_request_to_resolve_discussions_of` + iid: + type: integer + format: int32 + description: The internal ID of a project issue. Available only for admins + and project owners. + description: + type: string + description: The description of an issue + assignee_ids: + type: array + description: The array of user IDs to assign issue + items: + type: integer + format: int32 + assignee_id: + type: integer + format: int32 + description: "[Deprecated] The ID of a user to assign issue" + milestone_id: + type: integer + format: int32 + description: The ID of a milestone to assign issue + labels: + type: array + description: Comma-separated list of label names + items: + type: string + add_labels: + type: array + description: Comma-separated list of label names + items: + type: string + remove_labels: + type: array + description: Comma-separated list of label names + items: + type: string + due_date: + type: string + description: Date string in the format YEAR-MONTH-DAY + confidential: + type: boolean + description: Boolean parameter if the issue should be confidential + discussion_locked: + type: boolean + description: " Boolean parameter indicating if the issue's discussion is locked" + issue_type: + type: string + description: 'The type of the issue. Accepts: issue, incident, test_case, + requirement, task, ticket' + enum: + - issue + - incident + - test_case + - requirement + - task + - ticket + weight: + type: integer + format: int32 + description: The weight of the issue + epic_id: + type: integer + format: int32 + description: The ID of an epic to associate the issue with + epic_iid: + type: integer + format: int32 + description: The IID of an epic to associate the issue with (deprecated) + required: + - title + description: Create a new project issue + putApiV4ProjectsIdIssuesIssueIid: + type: object + properties: + title: + type: string + description: The title of an issue + updated_at: + type: string + format: date-time + description: Date time when the issue was updated. Available only for admins + and project owners. + state_event: + type: string + description: State of the issue + enum: + - reopen + - close + description: + type: string + description: The description of an issue + assignee_ids: + type: array + description: The array of user IDs to assign issue + items: + type: integer + format: int32 + assignee_id: + type: integer + format: int32 + description: "[Deprecated] The ID of a user to assign issue" + milestone_id: + type: integer + format: int32 + description: The ID of a milestone to assign issue + labels: + type: array + description: Comma-separated list of label names + items: + type: string + add_labels: + type: array + description: Comma-separated list of label names + items: + type: string + remove_labels: + type: array + description: Comma-separated list of label names + items: + type: string + due_date: + type: string + description: Date string in the format YEAR-MONTH-DAY + confidential: + type: boolean + description: Boolean parameter if the issue should be confidential + discussion_locked: + type: boolean + description: " Boolean parameter indicating if the issue's discussion is locked" + issue_type: + type: string + description: 'The type of the issue. Accepts: issue, incident, test_case, + requirement, task, ticket' + enum: + - issue + - incident + - test_case + - requirement + - task + - ticket + weight: + type: integer + format: int32 + description: The weight of the issue + epic_id: + type: integer + format: int32 + description: The ID of an epic to associate the issue with + epic_iid: + type: integer + format: int32 + description: The IID of an epic to associate the issue with (deprecated) + created_at: + type: string + description: Update an existing issue + putApiV4ProjectsIdIssuesIssueIidReorder: + type: object + properties: + move_after_id: + type: integer + format: int32 + description: The ID of the issue we want to be after + move_before_id: + type: integer + format: int32 + description: The ID of the issue we want to be before + description: Reorder an existing issue + postApiV4ProjectsIdIssuesIssueIidMove: + type: object + properties: + to_project_id: + type: integer + format: int32 + description: The ID of the new project + required: + - to_project_id + description: Move an existing issue + postApiV4ProjectsIdIssuesIssueIidClone: + type: object + properties: + to_project_id: + type: integer + format: int32 + description: The ID of the new project + with_notes: + type: boolean + description: Clone issue with notes + default: false + required: + - to_project_id + description: Clone an existing issue + API_Entities_UserAgentDetail: + type: object + properties: + user_agent: + type: string + example: AppleWebKit/537.36 + ip_address: + type: string + example: 127.0.0.1 + akismet_submitted: + type: boolean + example: false + description: API_Entities_UserAgentDetail model + postApiV4ProjectsIdIssuesIssueIidMetricImages: + type: object + properties: + file: + type: file + description: The image file to be uploaded + url: + type: string + description: The url to view more metric info + url_text: + type: string + description: A description of the image or URL + required: + - file + description: Upload a metric image for an issue + EE_API_Entities_IssuableMetricImage: + type: object + properties: + id: + type: string + created_at: + type: string + filename: + type: string + file_path: + type: string + url: + type: string + url_text: + type: string + description: EE_API_Entities_IssuableMetricImage model + putApiV4ProjectsIdIssuesIssueIidMetricImagesMetricImageId: + type: object + properties: + url: + type: string + description: The url to view more metric info + url_text: + type: string + description: A description of the image or URL + description: Update a metric image for an issue API_Entities_Ci_Lint_Result: type: object properties: @@ -60116,19 +62934,6 @@ definitions: required: - action description: Update an existing project snippet - API_Entities_UserAgentDetail: - type: object - properties: - user_agent: - type: string - example: AppleWebKit/537.36 - ip_address: - type: string - example: 127.0.0.1 - akismet_submitted: - type: boolean - example: false - description: API_Entities_UserAgentDetail model API_Entities_ProjectDailyStatistics: type: object properties: -- GitLab From 64b3fee293f58d96e2259a08128598e296d5c9c6 Mon Sep 17 00:00:00 2001 From: Igor Drozdov Date: Tue, 19 Aug 2025 18:36:23 +0200 Subject: [PATCH 4/6] Apply reviewers feedback --- lib/api/issues.rb | 2 +- lib/api/mcp/handlers/call_tool.rb | 8 +- lib/api/mcp/handlers/list_tools.rb | 4 +- lib/api/merge_requests.rb | 2 +- .../api/mcp/handlers/call_tool_spec.rb | 126 ++++++++++++++ .../api/mcp/handlers/list_tools_spec.rb | 62 +++++++ spec/requests/api/mcp/server_spec.rb | 156 ++++++++++++++++++ 7 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 spec/requests/api/mcp/handlers/call_tool_spec.rb create mode 100644 spec/requests/api/mcp/handlers/list_tools_spec.rb create mode 100644 spec/requests/api/mcp/server_spec.rb diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 903c3379f2b849..14d7d99d88d45b 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -256,7 +256,7 @@ class Issues < ::API::Base params do requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' end - route_setting :mcp, name: :get_issue, params: [:id, :issue_iid] + route_setting :mcp, tool_name: :get_issue, params: [:id, :issue_iid] get ":id/issues/:issue_iid", as: :api_v4_project_issue do issue = find_project_issue(params[:issue_iid]) present issue, with: Entities::Issue, current_user: current_user, project: user_project diff --git a/lib/api/mcp/handlers/call_tool.rb b/lib/api/mcp/handlers/call_tool.rb index fa614cf350e4b5..cb298a684dc8c1 100644 --- a/lib/api/mcp/handlers/call_tool.rb +++ b/lib/api/mcp/handlers/call_tool.rb @@ -4,7 +4,7 @@ module API module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#calltoolrequest - class CallTool < Base + class CallTool attr_reader :routes def initialize(routes) @@ -20,14 +20,14 @@ def invoke(request, params) private def find_route!(name) - route = routes.find { |route| route.app.route_setting(:mcp)[:name].to_s == name } + route = routes.find { |route| route.app.route_setting(:mcp)[:tool_name].to_s == name } raise ArgumentError, 'name is unsupported' unless route route end def perform_request(route, request, params) - args = params[:arguments].slice(*route.app.route_setting(:mcp)[:params]) + args = params[:arguments]&.slice(*route.app.route_setting(:mcp)[:params]) || {} request.env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(args) status, _, body = route.exec(request.env) @@ -37,7 +37,7 @@ def perform_request(route, request, params) def process_response(status, body) if status >= 400 parsed_response = Gitlab::Json.parse(body) - message = parsed_response['message'] || "HTTP #{status}" + message = parsed_response['error'] || parsed_response['message'] || "HTTP #{status}" ::Mcp::Tools::Response.error(message, parsed_response) else formatted_content = [{ type: 'text', text: body }] diff --git a/lib/api/mcp/handlers/list_tools.rb b/lib/api/mcp/handlers/list_tools.rb index 74bfc0da0bd890..8bdf25e8316c60 100644 --- a/lib/api/mcp/handlers/list_tools.rb +++ b/lib/api/mcp/handlers/list_tools.rb @@ -4,7 +4,7 @@ module API module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#listtoolsrequest - class ListTools < Base + class ListTools attr_reader :routes def initialize(routes) @@ -16,7 +16,7 @@ def invoke mcp_settings = route.app.route_setting(:mcp) { - name: mcp_settings[:name], + name: mcp_settings[:tool_name], description: route.description, inputSchema: build_input_schema(route, mcp_settings) } diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 32104b142a6069..e5e81e71f27940 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -398,7 +398,7 @@ def batch_process_mergeability_checks(merge_requests) ] tags %w[merge_requests] end - route_setting :mcp, name: :get_merge_request, params: [:id, :merge_request_iid] + route_setting :mcp, tool_name: :get_merge_request, params: [:id, :merge_request_iid] get ':id/merge_requests/:merge_request_iid', feature_category: :code_review_workflow, urgency: :low do merge_request = find_merge_request_with_access(params[:merge_request_iid]) diff --git a/spec/requests/api/mcp/handlers/call_tool_spec.rb b/spec/requests/api/mcp/handlers/call_tool_spec.rb new file mode 100644 index 00000000000000..6c57e428cbee8e --- /dev/null +++ b/spec/requests/api/mcp/handlers/call_tool_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "spec_helper" + +# rubocop:disable RSpec/SpecFilePathFormat -- JSON-RPC has single path for method invocation +RSpec.describe API::Mcp, 'Call tool request', feature_category: :mcp_server do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository, maintainers: [user]) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:access_token) { create(:oauth_access_token, user: user, scopes: [:mcp, :api]) } + + let(:params) do + { + jsonrpc: '2.0', + method: 'tools/call', + params: tool_params, + id: '1' + } + end + + describe 'POST /mcp_server with tools/call method' do + let(:tool_params) do + { name: 'get_issue', arguments: { id: project.full_path, issue_iid: issue.iid } } + end + + context 'with valid tool name' do + it 'returns success response' do + post api('/mcp_server', user, oauth_access_token: access_token), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['jsonrpc']).to eq(params[:jsonrpc]) + expect(json_response['id']).to eq(params[:id]) + expect(json_response.keys).to include('result') + expect(json_response['result']['content']).to be_an(Array) + expect(json_response['result']['content'].first['type']).to eq('text') + expect(json_response['result']['content'].first['text']).to include(issue.title) + expect(json_response['result']['isError']).to be_falsey + end + + context 'with insufficient scopes' do + let(:insufficient_access_token) { create(:oauth_access_token, user: user, scopes: [:api]) } + + it 'returns insufficient scopes error' do + post api('/mcp_server', user, oauth_access_token: insufficient_access_token), params: params + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when a user does not have access to the project' do + let_it_be(:issue) { create(:issue) } + let_it_be(:project) { issue.project } + + it 'returns not found' do + post api('/mcp_server', user, oauth_access_token: access_token), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['result']['isError']).to be_truthy + expect(json_response['result']['content'].first['text']).to include('404 Project Not Found') + end + end + end + + context 'with tool validation errors' do + let(:invalid_params) do + { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'get_issue', + arguments: { id: 'project-id' } + }, + id: '1' + } + end + + before do + post api('/mcp_server', user, oauth_access_token: access_token), params: invalid_params + end + + it 'returns success HTTP status with error result' do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['result']['isError']).to be_truthy + expect(json_response['result']['content'].first['text']).to include('iid is missing') + end + end + + context 'with unknown tool name' do + let(:params) do + { + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'unknown_tool' }, + id: '1' + } + end + + before do + post api('/mcp_server', user, oauth_access_token: access_token), params: params + end + + it 'returns invalid params error' do + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']['code']).to eq(-32602) + expect(json_response['error']['data']['params']).to eq('name is unsupported') + end + end + end + + describe '#get_merge_request' do + let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + + let(:tool_params) do + { name: 'get_merge_request', arguments: { id: project.full_path, merge_request_iid: merge_request.iid } } + end + + it 'returns success response' do + post api('/mcp_server', user, oauth_access_token: access_token), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['result']['content'].first['text']).to include(merge_request.title) + expect(json_response['result']['isError']).to be_falsey + end + end +end +# rubocop:enable RSpec/SpecFilePathFormat diff --git a/spec/requests/api/mcp/handlers/list_tools_spec.rb b/spec/requests/api/mcp/handlers/list_tools_spec.rb new file mode 100644 index 00000000000000..e3a66b41be3e5c --- /dev/null +++ b/spec/requests/api/mcp/handlers/list_tools_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +# rubocop:disable RSpec/SpecFilePathFormat -- JSON-RPC has single path for method invocation +RSpec.describe API::Mcp, 'List tools request', feature_category: :mcp_server do + let_it_be(:user) { create(:user) } + let_it_be(:access_token) { create(:oauth_access_token, user: user, scopes: [:mcp]) } + + describe 'POST /mcp_server with tools/list method' do + let(:params) do + { + jsonrpc: '2.0', + method: 'tools/list', + id: '1' + } + end + + before do + post api('/mcp_server', user, oauth_access_token: access_token), params: params + end + + it 'returns success' do + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['jsonrpc']).to eq(params[:jsonrpc]) + expect(json_response['id']).to eq(params[:id]) + expect(json_response.keys).to include('result') + end + + it 'returns tools' do + expect(json_response['result']['tools']).to eq([ + { + "name" => "get_issue", + "description" => "Get a single project issue", + "inputSchema" => { + "additionalProperties" => false, + "properties" => { + "id" => { "description" => "The ID or URL-encoded path of the project", "type" => "string" }, + "issue_iid" => { "description" => "The internal ID of a project issue", "type" => "integer" } + }, + "required" => %w[id issue_iid], + "type" => "object" + } + }, + { + "name" => "get_merge_request", + "description" => "Get single merge request", + "inputSchema" => { + "additionalProperties" => false, + "properties" => { + "id" => { "description" => "The ID or URL-encoded path of the project.", "type" => "string" }, + "merge_request_iid" => { "description" => "The internal ID of the merge request.", "type" => "integer" } + }, + "required" => %w[id merge_request_iid], + "type" => "object" + } + } + ]) + end + end +end +# rubocop:enable RSpec/SpecFilePathFormat diff --git a/spec/requests/api/mcp/server_spec.rb b/spec/requests/api/mcp/server_spec.rb new file mode 100644 index 00000000000000..f9fc27611e996b --- /dev/null +++ b/spec/requests/api/mcp/server_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe API::Mcp::Server, feature_category: :mcp_server do + let_it_be(:user) { create(:user) } + let_it_be(:access_token) { create(:oauth_access_token, user: user, scopes: [:mcp]) } + + describe 'POST /mcp_server' do + context 'when unauthenticated' do + it 'returns authentication error' do + post api('/mcp_server') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when authenticated' do + context 'when feature flag is disabled' do + before do + stub_feature_flags(mcp_server: false) + end + + it 'returns not found' do + post api('/mcp_server', user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when access token is PAT' do + it 'returns forbidden' do + post api('/mcp_server', user), params: { jsonrpc: '2.0', method: 'initialize', id: '1' } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when access token is OAuth without mcp scope' do + let(:insufficient_access_token) { create(:oauth_access_token, user: user, scopes: [:api]) } + + it 'returns forbidden' do + post api('/mcp_server', user, oauth_access_token: insufficient_access_token), + params: { jsonrpc: '2.0', method: 'initialize', id: '1' } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when required jsonrpc param is missing' do + it 'returns JSON-RPC Invalid Request error' do + post api('/mcp_server', user, oauth_access_token: access_token), params: { id: '1', method: 'initialize' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']['code']).to eq(-32600) + expect(json_response['error']['data']['validations']).to include('jsonrpc is missing') + end + end + + context 'when required jsonrpc param is empty' do + it 'returns JSON-RPC Invalid Request error' do + post api('/mcp_server', user, oauth_access_token: access_token), + params: { jsonrpc: '', method: 'initialize', id: '1' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']['code']).to eq(-32600) + expect(json_response['error']['data']['validations']).to include('jsonrpc is empty') + end + end + + context 'when required jsonrpc param is invalid value' do + it 'returns JSON-RPC Invalid Request error' do + post api('/mcp_server', user, oauth_access_token: access_token), + params: { jsonrpc: '1.0', method: 'initialize', id: '1' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']['code']).to eq(-32600) + expect(json_response['error']['data']['validations']).to include('jsonrpc does not have a valid value') + end + end + + context 'when required method param is missing' do + it 'returns JSON-RPC Invalid Request error' do + post api('/mcp_server', user, oauth_access_token: access_token), params: { jsonrpc: '2.0', id: '1' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']['code']).to eq(-32600) + expect(json_response['error']['data']['validations']).to include('method is missing') + end + end + + context 'when required method param is empty' do + it 'returns JSON-RPC Invalid Request error' do + post api('/mcp_server', user, oauth_access_token: access_token), + params: { jsonrpc: '2.0', method: '', id: '1' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']['code']).to eq(-32600) + expect(json_response['error']['data']['validations']).to include('method is empty') + end + end + + context 'when optional id param is empty' do + it 'returns JSON-RPC Invalid Request error' do + post api('/mcp_server', user, oauth_access_token: access_token), + params: { jsonrpc: '2.0', method: 'initialize', id: '' } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']['code']).to eq(-32600) + expect(json_response['error']['data']['validations']).to include('id is empty') + end + end + + context 'when method does not exist' do + it 'returns JSON-RPC Method not found error' do + post api('/mcp_server', user, oauth_access_token: access_token), + params: { jsonrpc: '2.0', method: 'unknown/method', id: '1' } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['error']['code']).to eq(-32601) + expect(json_response['error']['message']).to eq('Method not found') + end + end + end + end + + describe 'GET /mcp_server' do + context 'when unauthenticated' do + it 'returns authentication error' do + get api('/mcp_server') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when authenticated' do + context 'when feature flag is disabled' do + before do + stub_feature_flags(mcp_server: false) + end + + it 'returns not found' do + get api('/mcp_server', user, oauth_access_token: access_token) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + it 'returns not implemented' do + get api('/mcp_server', user, oauth_access_token: access_token) + + expect(response).to have_gitlab_http_status(:not_implemented) + end + end + end +end -- GitLab From 96dafe6eb9972af9c582bb4adbd4442f0f84f5ae Mon Sep 17 00:00:00 2001 From: Igor Drozdov Date: Tue, 26 Aug 2025 13:17:38 +0200 Subject: [PATCH 5/6] Introduce tools manager and api tools type --- app/services/mcp/tools/api_tool.rb | 71 ++++ app/services/mcp/tools/manager.rb | 31 ++ lib/api/mcp/handlers/call_tool.rb | 40 +-- lib/api/mcp/handlers/list_tools.rb | 45 +-- lib/api/mcp/server.rb | 6 +- .../api/mcp/handlers/list_tools_spec.rb | 9 + spec/services/mcp/tools/api_tool_spec.rb | 330 ++++++++++++++++++ spec/services/mcp/tools/manager_spec.rb | 131 +++++++ 8 files changed, 591 insertions(+), 72 deletions(-) create mode 100644 app/services/mcp/tools/api_tool.rb create mode 100644 app/services/mcp/tools/manager.rb create mode 100644 spec/services/mcp/tools/api_tool_spec.rb create mode 100644 spec/services/mcp/tools/manager_spec.rb diff --git a/app/services/mcp/tools/api_tool.rb b/app/services/mcp/tools/api_tool.rb new file mode 100644 index 00000000000000..cf5240b0cd4c53 --- /dev/null +++ b/app/services/mcp/tools/api_tool.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Mcp + module Tools + class ApiTool + attr_reader :route, :settings + + def initialize(route) + @route = route + @settings = route.app.route_setting(:mcp) + end + + def description + route.description + end + + def input_schema + params = route.params.slice(*settings[:params].map(&:to_s)) + required_fields = params.filter_map do |param, values| + param if values[:required] + end + + properties = params.transform_values do |value| + { type: parse_type(value[:type]), description: value[:desc] } + end + + { + type: 'object', + properties: properties, + required: required_fields, + additionalProperties: false + } + end + + def execute(request, params) + args = params[:arguments]&.slice(*settings[:params]) || {} + request.env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(args) + + status, _, body = route.exec(request.env) + process_response(status, Array(body)[0]) + end + + private + + def parse_type(type) + array_str_match = type.match(/^\[(.*)\]$/) + if array_str_match + return array_str_match[1].split(", ")[0].downcase # return the first element from [String, Integer] types + end + + return 'boolean' if type == 'Grape::API::Boolean' + return 'array' if type.start_with?('Array') + + type.downcase + end + + def process_response(status, body) + if status >= 400 + parsed_response = Gitlab::Json.parse(body) + message = parsed_response['error'] || parsed_response['message'] || "HTTP #{status}" + ::Mcp::Tools::Response.error(message, parsed_response) + else + formatted_content = [{ type: 'text', text: body }] + ::Mcp::Tools::Response.success(formatted_content, []) + end + rescue JSON::ParserError => e + ::Mcp::Tools::Response.error('Invalid JSON response', { message: e.message }) + end + end + end +end diff --git a/app/services/mcp/tools/manager.rb b/app/services/mcp/tools/manager.rb new file mode 100644 index 00000000000000..9fdd7325643b82 --- /dev/null +++ b/app/services/mcp/tools/manager.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mcp + module Tools + class Manager + CUSTOM_TOOLS = { + 'get_mcp_server_version' => ::Mcp::Tools::GetServerVersionService.new(name: 'get_mcp_server_version') + }.freeze + + attr_reader :tools + + def initialize + @tools = build_tools + end + + private + + def build_tools + api_tools = {} + ::API::API.routes.each do |route| + settings = route.app.route_setting(:mcp) + next if settings.blank? + + api_tools[settings[:tool_name].to_s] = Mcp::Tools::ApiTool.new(route) + end + + { **api_tools, **CUSTOM_TOOLS } + end + end + end +end diff --git a/lib/api/mcp/handlers/call_tool.rb b/lib/api/mcp/handlers/call_tool.rb index cb298a684dc8c1..a2ddb5374c36d2 100644 --- a/lib/api/mcp/handlers/call_tool.rb +++ b/lib/api/mcp/handlers/call_tool.rb @@ -5,46 +5,24 @@ module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#calltoolrequest class CallTool - attr_reader :routes - - def initialize(routes) - @routes = routes + def initialize(manager) + @manager = manager end def invoke(request, params) - route = find_route!(params[:name]) - status, body = perform_request(route, request, params) - process_response(status, body) + tool = find_tool!(params[:name]) + tool.execute(request, params) end private - def find_route!(name) - route = routes.find { |route| route.app.route_setting(:mcp)[:tool_name].to_s == name } - raise ArgumentError, 'name is unsupported' unless route - - route - end + attr_reader :manager - def perform_request(route, request, params) - args = params[:arguments]&.slice(*route.app.route_setting(:mcp)[:params]) || {} - request.env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(args) - status, _, body = route.exec(request.env) - - [status, Array(body)[0]] - end + def find_tool!(name) + tool = manager.tools[name] + raise ArgumentError, 'name is unsupported' unless tool - def process_response(status, body) - if status >= 400 - parsed_response = Gitlab::Json.parse(body) - message = parsed_response['error'] || parsed_response['message'] || "HTTP #{status}" - ::Mcp::Tools::Response.error(message, parsed_response) - else - formatted_content = [{ type: 'text', text: body }] - ::Mcp::Tools::Response.success(formatted_content, []) - end - rescue JSON::ParserError => e - ::Mcp::Tools::Response.error('Invalid JSON response', { message: e.message }) + tool end end end diff --git a/lib/api/mcp/handlers/list_tools.rb b/lib/api/mcp/handlers/list_tools.rb index 8bdf25e8316c60..aa68399358b254 100644 --- a/lib/api/mcp/handlers/list_tools.rb +++ b/lib/api/mcp/handlers/list_tools.rb @@ -5,20 +5,16 @@ module Mcp module Handlers # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#listtoolsrequest class ListTools - attr_reader :routes - - def initialize(routes) - @routes = routes + def initialize(manager) + @manager = manager end def invoke - tools = routes.map do |route| - mcp_settings = route.app.route_setting(:mcp) - + tools = manager.tools.map do |name, tool| { - name: mcp_settings[:tool_name], - description: route.description, - inputSchema: build_input_schema(route, mcp_settings) + name: name, + description: tool.description, + inputSchema: tool.input_schema } end @@ -27,34 +23,7 @@ def invoke private - def build_input_schema(route, mcp_settings) - required_fields = route.params.filter_map do |param, values| - param if values[:required] - end - - properties = route.params.slice(*mcp_settings[:params].map(&:to_s)).transform_values do |value| - { type: parse_type(value[:type]), description: value[:desc] } - end - - { - type: 'object', - properties: properties, - required: required_fields, - additionalProperties: false - } - end - - def parse_type(type) - array_str_match = type.match(/^\[(.*)\]$/) - if array_str_match - return array_str_match[1].split(", ")[0].downcase # return the first element from [String, Integer] types - end - - return 'boolean' if type == 'Grape::API::Boolean' - return 'array' if type.start_with?('Array') - - type.downcase - end + attr_reader :manager end end end diff --git a/lib/api/mcp/server.rb b/lib/api/mcp/server.rb index 011f5be92ef427..ba9d5a91988636 100644 --- a/lib/api/mcp/server.rb +++ b/lib/api/mcp/server.rb @@ -80,7 +80,7 @@ def format_jsonrpc_response(result) # Model Context Protocol (MCP) specification # See: https://modelcontextprotocol.io/specification/2025-06-18 namespace :mcp_server do - namespace_setting :mcp_routes, ::API::API.routes.select { |route| route.app.route_setting(:mcp) } + namespace_setting :mcp_manager, ::Mcp::Tools::Manager.new params do # JSON-RPC Request Object # See: https://www.jsonrpc.org/specification#request_object @@ -113,9 +113,9 @@ def format_jsonrpc_response(result) result = case params[:method] when 'tools/call' - Handlers::CallTool.new(namespace_setting(:mcp_routes)).invoke(request, params[:params]) + Handlers::CallTool.new(namespace_setting(:mcp_manager)).invoke(request, params[:params]) when 'tools/list' - Handlers::ListTools.new(namespace_setting(:mcp_routes)).invoke + Handlers::ListTools.new(namespace_setting(:mcp_manager)).invoke else invoke_basic_handler end diff --git a/spec/requests/api/mcp/handlers/list_tools_spec.rb b/spec/requests/api/mcp/handlers/list_tools_spec.rb index e3a66b41be3e5c..cde78355c0f9a6 100644 --- a/spec/requests/api/mcp/handlers/list_tools_spec.rb +++ b/spec/requests/api/mcp/handlers/list_tools_spec.rb @@ -54,6 +54,15 @@ "required" => %w[id merge_request_iid], "type" => "object" } + }, + { + "name" => "get_mcp_server_version", + "description" => "Get the current version of MCP server.", + "inputSchema" => { + "properties" => {}, + "required" => [], + "type" => "object" + } } ]) end diff --git a/spec/services/mcp/tools/api_tool_spec.rb b/spec/services/mcp/tools/api_tool_spec.rb new file mode 100644 index 00000000000000..84f9685df538b3 --- /dev/null +++ b/spec/services/mcp/tools/api_tool_spec.rb @@ -0,0 +1,330 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mcp::Tools::ApiTool, feature_category: :ai_agents do + let(:app) { instance_double(Grape::Endpoint) } + let(:mcp_settings) { { params: [:param1, :param2] } } + let(:route_params) do + { + 'param1' => { required: true, type: 'String', desc: 'First parameter' }, + 'param2' => { required: false, type: 'Integer', desc: 'Second parameter' }, + 'param3' => { required: true, type: 'Boolean', desc: 'Third parameter' } + } + end + + let(:route) do + instance_double(Grape::Router::Route, + app: app, + description: 'Test API endpoint', + params: route_params, + exec: [200, {}, ['{"success": true}']]) + end + + before do + allow(app).to receive(:route_setting).with(:mcp).and_return(mcp_settings) + end + + subject(:api_tool) { described_class.new(route) } + + describe '#initialize' do + it 'sets the route and settings' do + expect(api_tool.route).to eq(route) + expect(api_tool.settings).to eq(mcp_settings) + end + end + + describe '#description' do + it 'returns the route description' do + expect(api_tool.description).to eq('Test API endpoint') + end + end + + describe '#input_schema' do + context 'with standard types' do + it 'returns a valid JSON schema with required fields' do + schema = api_tool.input_schema + + expect(schema).to eq({ + type: 'object', + properties: { + 'param1' => { type: 'string', description: 'First parameter' }, + 'param2' => { type: 'integer', description: 'Second parameter' } + }, + required: ['param1'], + additionalProperties: false + }) + end + end + + context 'with boolean type' do + let(:mcp_settings) { { params: [:param3] } } + + let(:route_params) do + { + 'param3' => { required: true, type: 'Grape::API::Boolean', desc: 'Third parameter' } + } + end + + it 'converts Grape::API::Boolean to boolean' do + schema = api_tool.input_schema + + expect(schema[:properties]['param3'][:type]).to eq('boolean') + end + end + + context 'with array types' do + let(:route_params) do + { + 'array_param' => { required: true, type: '[String, Integer]', desc: 'Array parameter' } + } + end + + let(:mcp_settings) { { params: [:array_param] } } + + it 'extracts first element type from array notation' do + schema = api_tool.input_schema + + expect(schema[:properties]['array_param'][:type]).to eq('string') + end + end + + context 'with Array type' do + let(:route_params) do + { + 'array_param' => { required: false, type: 'Array', desc: 'Array parameter' } + } + end + + let(:mcp_settings) { { params: [:array_param] } } + + it 'returns array type for Array types' do + schema = api_tool.input_schema + + expect(schema[:properties]['array_param'][:type]).to eq('array') + end + end + + context 'when no required fields' do + let(:route_params) do + { + 'param1' => { required: false, type: 'String', desc: 'Optional parameter' } + } + end + + it 'returns empty required array' do + schema = api_tool.input_schema + + expect(schema[:required]).to eq([]) + end + end + + context 'when settings params filter out some route params' do + let(:mcp_settings) { { params: [:param1] } } + + it 'only includes params specified in settings' do + schema = api_tool.input_schema + + expect(schema[:properties].keys).to eq(['param1']) + end + end + end + + describe '#execute' do + let(:request) { instance_double(Rack::Request, env: request_env) } + let(:request_env) { { 'grape.routing_args' => {} } } + let(:params) { { arguments: { param1: 'value1', param2: 42 } } } + + context 'with successful response' do + before do + allow(route).to receive(:exec).with(request_env).and_return([200, {}, ['{"result": "success"}']]) + end + + it 'merges arguments into routing args and executes route' do + result = api_tool.execute(request, params) + + expect(request_env['grape.routing_args']).to include(param1: 'value1', param2: 42) + expect(result).to eq(Mcp::Tools::Response.success([{ type: 'text', text: '{"result": "success"}' }], [])) + end + end + + context 'with error response' do + before do + allow(route).to receive(:exec).with(request_env).and_return([400, {}, ['{"error": "Bad request"}']]) + end + + it 'returns error response with parsed message' do + result = api_tool.execute(request, params) + + expect(result).to eq(Mcp::Tools::Response.error('Bad request', { 'error' => 'Bad request' })) + end + end + + context 'with error response containing message field' do + before do + allow(route).to receive(:exec).with(request_env).and_return([422, {}, ['{"message": "Validation failed"}']]) + end + + it 'uses message field for error' do + result = api_tool.execute(request, params) + + expect(result).to eq(Mcp::Tools::Response.error('Validation failed', { 'message' => 'Validation failed' })) + end + end + + context 'with error response without error or message fields' do + before do + allow(route).to receive(:exec).with(request_env).and_return([500, {}, ['{"details": "Internal error"}']]) + end + + it 'falls back to HTTP status message' do + result = api_tool.execute(request, params) + + expect(result).to eq(Mcp::Tools::Response.error('HTTP 500', { 'details' => 'Internal error' })) + end + end + + context 'with invalid JSON response' do + before do + allow(route).to receive(:exec).with(request_env).and_return([500, {}, ['invalid json']]) + end + + it 'returns JSON parsing error' do + result = api_tool.execute(request, params) + + expect(result[:content][0][:text]).to eq('Invalid JSON response') + end + end + + context 'with nil params' do + let(:params) { {} } + + before do + allow(route).to receive(:exec).with(request_env).and_return([200, {}, ['success']]) + end + + it 'handles nil arguments gracefully' do + result = api_tool.execute(request, params) + + expect(request_env['grape.routing_args']).to eq({}) + expect(result).to eq(Mcp::Tools::Response.success([{ type: 'text', text: 'success' }], [])) + end + end + + context 'with filtered arguments based on settings' do + let(:params) { { arguments: { param1: 'value1', param2: 42, unauthorized_param: 'hack' } } } + + before do + allow(route).to receive(:exec).with(request_env).and_return([200, {}, ['success']]) + end + + it 'only includes params specified in settings' do + api_tool.execute(request, params) + + expect(request_env['grape.routing_args']).to eq(param1: 'value1', param2: 42) + expect(request_env['grape.routing_args']).not_to have_key(:unauthorized_param) + end + end + + context 'with body as array with multiple elements' do + before do + allow(route).to receive(:exec).with(request_env).and_return([200, {}, %w[first second]]) + end + + it 'uses first element of body array' do + result = api_tool.execute(request, params) + + expect(result).to eq(Mcp::Tools::Response.success([{ type: 'text', text: 'first' }], [])) + end + end + end + + describe '#parse_type (private method)' do + describe 'type parsing through input_schema' do + let(:route_params) do + { + 'string_param' => { required: false, type: 'String', desc: 'String param' }, + 'integer_param' => { required: false, type: 'Integer', desc: 'Integer param' }, + 'boolean_param' => { required: false, type: 'Grape::API::Boolean', desc: 'Boolean param' }, + 'array_param' => { required: false, type: 'Array', desc: 'Array param' }, + 'complex_array_param' => { required: false, type: '[String, Integer]', desc: 'Complex array param' } + } + end + + let(:mcp_settings) do + { params: [:string_param, :integer_param, :boolean_param, :array_param, :complex_array_param] } + end + + it 'correctly parses different types' do + schema = api_tool.input_schema + + expect(schema[:properties]['string_param'][:type]).to eq('string') + expect(schema[:properties]['integer_param'][:type]).to eq('integer') + expect(schema[:properties]['boolean_param'][:type]).to eq('boolean') + expect(schema[:properties]['array_param'][:type]).to eq('array') + expect(schema[:properties]['complex_array_param'][:type]).to eq('string') + end + end + end + + describe 'integration with Mcp::Tools::Response' do + let(:request) { instance_double(Rack::Request, env: { 'grape.routing_args' => {} }) } + let(:params) { { arguments: { param1: 'test' } } } + + before do + allow(Mcp::Tools::Response).to receive_messages(success: { success: true }, error: { error: true }) + end + + it 'calls Response.success for successful requests' do + allow(route).to receive(:exec).and_return([200, {}, ['success']]) + + api_tool.execute(request, params) + + expect(Mcp::Tools::Response).to have_received(:success).with([{ type: 'text', text: 'success' }], []) + end + + it 'calls Response.error for error requests' do + allow(route).to receive(:exec).and_return([400, {}, ['{"error": "Bad request"}']]) + + api_tool.execute(request, params) + + expect(Mcp::Tools::Response).to have_received(:error).with('Bad request', { 'error' => 'Bad request' }) + end + end + + describe 'edge cases and error handling' do + let(:request) { instance_double(Rack::Request, env: { 'grape.routing_args' => {} }) } + let(:params) { { arguments: { param1: 'test' } } } + + context 'when route.exec raises an exception' do + before do + allow(route).to receive(:exec).and_raise(StandardError.new('Route execution failed')) + end + + it 'does not rescue the exception' do + expect { api_tool.execute(request, params) }.to raise_error(StandardError, 'Route execution failed') + end + end + + context 'when route has no params' do + let(:route_params) { {} } + + it 'returns empty schema properties' do + schema = api_tool.input_schema + + expect(schema[:properties]).to eq({}) + expect(schema[:required]).to eq([]) + end + end + + context 'when settings has empty params array' do + let(:mcp_settings) { { params: [] } } + + it 'returns empty schema properties' do + schema = api_tool.input_schema + + expect(schema[:properties]).to eq({}) + end + end + end +end diff --git a/spec/services/mcp/tools/manager_spec.rb b/spec/services/mcp/tools/manager_spec.rb new file mode 100644 index 00000000000000..c6a36c839ba4dc --- /dev/null +++ b/spec/services/mcp/tools/manager_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mcp::Tools::Manager, feature_category: :ai_agents do + let(:custom_service) { Mcp::Tools::GetServerVersionService.new(name: 'get_mcp_server_version') } + + before do + stub_const("#{described_class}::CUSTOM_TOOLS", { 'get_mcp_server_version' => custom_service }) + end + + describe '#initialize' do + let(:api_double) { class_double(API::API) } + let(:routes) { [] } + + before do + stub_const('API::API', api_double) + allow(api_double).to receive(:routes).and_return(routes) + end + + context 'with no API routes' do + it 'initializes with only custom tools' do + manager = described_class.new + + expect(manager.tools).to eq(described_class::CUSTOM_TOOLS) + expect(manager.tools.keys).to contain_exactly('get_mcp_server_version') + end + end + + context 'with API routes that have MCP settings' do + let(:app1) { instance_double(Grape::Endpoint) } + let(:app2) { instance_double(Grape::Endpoint) } + let(:route1) { instance_double(Grape::Router::Route, app: app1) } + let(:route2) { instance_double(Grape::Router::Route, app: app2) } + let(:routes) { [route1, route2] } + let(:mcp_settings1) { { tool_name: :create_user, params: [:name, :email] } } + let(:mcp_settings2) { { tool_name: :delete_user, params: [:id] } } + let(:api_tool1) { instance_double(Mcp::Tools::ApiTool) } + let(:api_tool2) { instance_double(Mcp::Tools::ApiTool) } + + before do + allow(app1).to receive(:route_setting).with(:mcp).and_return(mcp_settings1) + allow(app2).to receive(:route_setting).with(:mcp).and_return(mcp_settings2) + allow(Mcp::Tools::ApiTool).to receive(:new).with(route1).and_return(api_tool1) + allow(Mcp::Tools::ApiTool).to receive(:new).with(route2).and_return(api_tool2) + end + + it 'creates ApiTool instances for routes with MCP settings' do + manager = described_class.new + + expect(manager.tools).to include( + 'create_user' => api_tool1, + 'delete_user' => api_tool2, + 'get_mcp_server_version' => custom_service + ) + expect(manager.tools.size).to eq(3) + end + + it 'converts tool_name symbols to strings' do + manager = described_class.new + + expect(manager.tools.keys).to include('create_user', 'delete_user') + expect(manager.tools.keys).not_to include(:create_user, :delete_user) + end + end + + context 'with API routes that have blank MCP settings' do + let(:app1) { instance_double(Grape::Endpoint) } + let(:app2) { instance_double(Grape::Endpoint) } + let(:app3) { instance_double(Grape::Endpoint) } + let(:route1) { instance_double(Grape::Router::Route, app: app1) } + let(:route2) { instance_double(Grape::Router::Route, app: app2) } + let(:route3) { instance_double(Grape::Router::Route, app: app3) } + let(:routes) { [route1, route2, route3] } + let(:mcp_settings1) { { tool_name: :valid_tool, params: [:param] } } + let(:api_tool1) { instance_double(Mcp::Tools::ApiTool) } + + before do + allow(app1).to receive(:route_setting).with(:mcp).and_return(mcp_settings1) + allow(app2).to receive(:route_setting).with(:mcp).and_return(nil) + allow(app3).to receive(:route_setting).with(:mcp).and_return({}) + allow(Mcp::Tools::ApiTool).to receive(:new).with(route1).and_return(api_tool1) + end + + it 'skips routes with blank MCP settings' do + manager = described_class.new + + expect(manager.tools).to include( + 'valid_tool' => api_tool1, + 'get_mcp_server_version' => custom_service + ) + expect(manager.tools.size).to eq(2) + expect(Mcp::Tools::ApiTool).to have_received(:new).once.with(route1) + expect(Mcp::Tools::ApiTool).not_to have_received(:new).with(route2) + expect(Mcp::Tools::ApiTool).not_to have_received(:new).with(route3) + end + end + + context 'with mixed mcp and non-mcp routes' do + let(:app1) { instance_double(Grape::Endpoint) } + let(:app2) { instance_double(Grape::Endpoint) } + let(:app3) { instance_double(Grape::Endpoint) } + let(:route1) { instance_double(Grape::Router::Route, app: app1) } + let(:route2) { instance_double(Grape::Router::Route, app: app2) } + let(:route3) { instance_double(Grape::Router::Route, app: app3) } + let(:routes) { [route1, route2, route3] } + let(:mcp_settings1) { { tool_name: :first_tool, params: [:param1] } } + let(:mcp_settings3) { { tool_name: :third_tool, params: [:param3] } } + let(:api_tool1) { instance_double(Mcp::Tools::ApiTool) } + let(:api_tool3) { instance_double(Mcp::Tools::ApiTool) } + + before do + allow(app1).to receive(:route_setting).with(:mcp).and_return(mcp_settings1) + allow(app2).to receive(:route_setting).with(:mcp).and_return(nil) + allow(app3).to receive(:route_setting).with(:mcp).and_return(mcp_settings3) + allow(Mcp::Tools::ApiTool).to receive(:new).with(route1).and_return(api_tool1) + allow(Mcp::Tools::ApiTool).to receive(:new).with(route3).and_return(api_tool3) + end + + it 'only processes valid routes and merges with custom tools' do + manager = described_class.new + + expect(manager.tools).to eq( + 'first_tool' => api_tool1, + 'third_tool' => api_tool3, + 'get_mcp_server_version' => custom_service + ) + end + end + end +end -- GitLab From 939b156ff57430817f9333879a910cdb9652dc12 Mon Sep 17 00:00:00 2001 From: Igor Drozdov Date: Tue, 26 Aug 2025 20:29:51 +0200 Subject: [PATCH 6/6] Specify multiple mcp server urls in protected resource --- .../protected_resource_metadata_controller.rb | 5 +- app/services/mcp/tools/api_tool.rb | 5 +- .../api/mcp/handlers/call_tool_spec.rb | 5 + ...ected_resource_metadata_controller_spec.rb | 22 +++-- spec/services/mcp/tools/api_tool_spec.rb | 92 ++++++++++--------- 5 files changed, 78 insertions(+), 51 deletions(-) diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb index b3372a0384dbdd..5039722a046bd3 100644 --- a/app/controllers/oauth/protected_resource_metadata_controller.rb +++ b/app/controllers/oauth/protected_resource_metadata_controller.rb @@ -16,7 +16,10 @@ def show def resource_metadata { - resource: "#{request.base_url}/api/v4/mcp", + resource: [ + "#{request.base_url}/api/v4/mcp", + "#{request.base_url}/api/v4/mcp_server" + ], authorization_servers: [ request.base_url ] diff --git a/app/services/mcp/tools/api_tool.rb b/app/services/mcp/tools/api_tool.rb index cf5240b0cd4c53..b0c8b39072dfb2 100644 --- a/app/services/mcp/tools/api_tool.rb +++ b/app/services/mcp/tools/api_tool.rb @@ -35,6 +35,7 @@ def input_schema def execute(request, params) args = params[:arguments]&.slice(*settings[:params]) || {} request.env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(args) + request.env[Rack::REQUEST_METHOD] = route.request_method status, _, body = route.exec(request.env) process_response(status, Array(body)[0]) @@ -55,13 +56,13 @@ def parse_type(type) end def process_response(status, body) + parsed_response = Gitlab::Json.parse(body) if status >= 400 - parsed_response = Gitlab::Json.parse(body) message = parsed_response['error'] || parsed_response['message'] || "HTTP #{status}" ::Mcp::Tools::Response.error(message, parsed_response) else formatted_content = [{ type: 'text', text: body }] - ::Mcp::Tools::Response.success(formatted_content, []) + ::Mcp::Tools::Response.success(formatted_content, parsed_response) end rescue JSON::ParserError => e ::Mcp::Tools::Response.error('Invalid JSON response', { message: e.message }) diff --git a/spec/requests/api/mcp/handlers/call_tool_spec.rb b/spec/requests/api/mcp/handlers/call_tool_spec.rb index 6c57e428cbee8e..546653be8ce297 100644 --- a/spec/requests/api/mcp/handlers/call_tool_spec.rb +++ b/spec/requests/api/mcp/handlers/call_tool_spec.rb @@ -34,6 +34,7 @@ expect(json_response['result']['content']).to be_an(Array) expect(json_response['result']['content'].first['type']).to eq('text') expect(json_response['result']['content'].first['text']).to include(issue.title) + expect(json_response['result']['structuredContent']['title']).to eq(issue.title) expect(json_response['result']['isError']).to be_falsey end @@ -57,6 +58,9 @@ expect(response).to have_gitlab_http_status(:ok) expect(json_response['result']['isError']).to be_truthy expect(json_response['result']['content'].first['text']).to include('404 Project Not Found') + expect(json_response['result']['structuredContent']).to eq({ + "error" => { "message" => "404 Project Not Found" } + }) end end end @@ -119,6 +123,7 @@ expect(response).to have_gitlab_http_status(:ok) expect(json_response['result']['content'].first['text']).to include(merge_request.title) + expect(json_response['result']['structuredContent']['title']).to eq(merge_request.title) expect(json_response['result']['isError']).to be_falsey end end diff --git a/spec/requests/oauth/protected_resource_metadata_controller_spec.rb b/spec/requests/oauth/protected_resource_metadata_controller_spec.rb index 22c7a3ce835fa2..791ba5b11e4d02 100644 --- a/spec/requests/oauth/protected_resource_metadata_controller_spec.rb +++ b/spec/requests/oauth/protected_resource_metadata_controller_spec.rb @@ -7,7 +7,10 @@ let(:protected_resource_path) { Gitlab::Routing.url_helpers.oauth_protected_resource_metadata_path } let(:expected_response) do { - 'resource' => "http://www.example.com/api/v4/mcp", + 'resource' => [ + "http://www.example.com/api/v4/mcp", + "http://www.example.com/api/v4/mcp_server" + ], 'authorization_servers' => [ "http://www.example.com" ] @@ -51,7 +54,10 @@ it 'returns metadata with custom base URL' do expected_custom_response = { - 'resource' => "#{custom_host}/api/v4/mcp", + 'resource' => [ + "#{custom_host}/api/v4/mcp", + "#{custom_host}/api/v4/mcp_server" + ], 'authorization_servers' => [ custom_host ] @@ -91,11 +97,15 @@ end it 'has correct resource format' do - resource_url = response.parsed_body['resource'] + resource_urls = response.parsed_body['resource'] + expect(resource_urls.size).to eq(2) - expect(resource_url).to be_a(String) + expect(resource_urls).to all be_a(String) + expect(resource_urls).to all match(%r{\Ahttps?://}) + + resource_url, another_resource_url = resource_urls expect(resource_url).to end_with('/api/v4/mcp') - expect(resource_url).to match(%r{\Ahttps?://}) + expect(another_resource_url).to end_with('/api/v4/mcp_server') end it 'has correct authorization_servers format' do @@ -109,7 +119,7 @@ it 'authorization_servers contains the same base URL as resource' do response_body = response.parsed_body - resource_base = response_body['resource'].gsub('/api/v4/mcp', '') + resource_base = response_body['resource'][0].gsub('/api/v4/mcp', '') auth_server = response_body['authorization_servers'].first expect(auth_server).to eq(resource_base) diff --git a/spec/services/mcp/tools/api_tool_spec.rb b/spec/services/mcp/tools/api_tool_spec.rb index 84f9685df538b3..5e52e4e4578e27 100644 --- a/spec/services/mcp/tools/api_tool_spec.rb +++ b/spec/services/mcp/tools/api_tool_spec.rb @@ -18,6 +18,7 @@ app: app, description: 'Test API endpoint', params: route_params, + request_method: 'POST', exec: [200, {}, ['{"success": true}']]) end @@ -140,11 +141,44 @@ allow(route).to receive(:exec).with(request_env).and_return([200, {}, ['{"result": "success"}']]) end - it 'merges arguments into routing args and executes route' do + it 'merges arguments into routing args, sets request method, and executes route' do result = api_tool.execute(request, params) expect(request_env['grape.routing_args']).to include(param1: 'value1', param2: 42) - expect(result).to eq(Mcp::Tools::Response.success([{ type: 'text', text: '{"result": "success"}' }], [])) + expect(request_env[Rack::REQUEST_METHOD]).to eq('POST') + expect(result).to eq({ + content: [ + { + text: "{\"result\": \"success\"}", + type: "text" + } + ], + isError: false, + structuredContent: { + "result" => "success" + } + }) + end + end + + context 'with different HTTP methods' do + let(:route) do + instance_double(Grape::Router::Route, + app: app, + description: 'Test API endpoint', + params: route_params, + request_method: 'GET', + exec: [200, {}, ['{"result": "success"}']]) + end + + before do + allow(route).to receive(:exec).with(request_env).and_return([200, {}, ['{"result": "success"}']]) + end + + it 'sets the correct request method in environment' do + api_tool.execute(request, params) + + expect(request_env[Rack::REQUEST_METHOD]).to eq('GET') end end @@ -156,6 +190,7 @@ it 'returns error response with parsed message' do result = api_tool.execute(request, params) + expect(request_env[Rack::REQUEST_METHOD]).to eq('POST') expect(result).to eq(Mcp::Tools::Response.error('Bad request', { 'error' => 'Bad request' })) end end @@ -200,14 +235,24 @@ let(:params) { {} } before do - allow(route).to receive(:exec).with(request_env).and_return([200, {}, ['success']]) + allow(route).to receive(:exec).with(request_env).and_return([200, {}, [{ 'result' => 'success' }.to_json]]) end it 'handles nil arguments gracefully' do result = api_tool.execute(request, params) expect(request_env['grape.routing_args']).to eq({}) - expect(result).to eq(Mcp::Tools::Response.success([{ type: 'text', text: 'success' }], [])) + expect(request_env[Rack::REQUEST_METHOD]).to eq('POST') + expect(result).to eq({ + content: [ + { + text: "{\"result\":\"success\"}", + type: "text" + } + ], + isError: false, + structuredContent: { "result" => "success" } + }) end end @@ -215,7 +260,7 @@ let(:params) { { arguments: { param1: 'value1', param2: 42, unauthorized_param: 'hack' } } } before do - allow(route).to receive(:exec).with(request_env).and_return([200, {}, ['success']]) + allow(route).to receive(:exec).with(request_env).and_return([200, {}, [{ 'result' => 'success' }.to_json]]) end it 'only includes params specified in settings' do @@ -225,18 +270,6 @@ expect(request_env['grape.routing_args']).not_to have_key(:unauthorized_param) end end - - context 'with body as array with multiple elements' do - before do - allow(route).to receive(:exec).with(request_env).and_return([200, {}, %w[first second]]) - end - - it 'uses first element of body array' do - result = api_tool.execute(request, params) - - expect(result).to eq(Mcp::Tools::Response.success([{ type: 'text', text: 'first' }], [])) - end - end end describe '#parse_type (private method)' do @@ -267,31 +300,6 @@ end end - describe 'integration with Mcp::Tools::Response' do - let(:request) { instance_double(Rack::Request, env: { 'grape.routing_args' => {} }) } - let(:params) { { arguments: { param1: 'test' } } } - - before do - allow(Mcp::Tools::Response).to receive_messages(success: { success: true }, error: { error: true }) - end - - it 'calls Response.success for successful requests' do - allow(route).to receive(:exec).and_return([200, {}, ['success']]) - - api_tool.execute(request, params) - - expect(Mcp::Tools::Response).to have_received(:success).with([{ type: 'text', text: 'success' }], []) - end - - it 'calls Response.error for error requests' do - allow(route).to receive(:exec).and_return([400, {}, ['{"error": "Bad request"}']]) - - api_tool.execute(request, params) - - expect(Mcp::Tools::Response).to have_received(:error).with('Bad request', { 'error' => 'Bad request' }) - end - end - describe 'edge cases and error handling' do let(:request) { instance_double(Rack::Request, env: { 'grape.routing_args' => {} }) } let(:params) { { arguments: { param1: 'test' } } } -- GitLab