diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb index b3372a0384dbdd9001fb7f3ea47197f683e16f7d..5039722a046bd3fb567f1712cee59d0bcf102c87 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 new file mode 100644 index 0000000000000000000000000000000000000000..b0c8b39072dfb26f8507d57030d7dcfbfa3fcedd --- /dev/null +++ b/app/services/mcp/tools/api_tool.rb @@ -0,0 +1,72 @@ +# 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) + request.env[Rack::REQUEST_METHOD] = route.request_method + + 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) + parsed_response = Gitlab::Json.parse(body) + if status >= 400 + 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, parsed_response) + 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 0000000000000000000000000000000000000000..9fdd7325643b82bfdd608da7afe492b3369b3789 --- /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/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index e97d1c4ed4bb645d6a44d5d84ed4ad360ce76e60..b774db75ce882ad7d4a0f987f8aab3d5290178df 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: diff --git a/lib/api/api.rb b/lib/api/api.rb index 07a204f7ed45131aea8da12a51e258209fb84a12..8f1f3fc72b965f16a14015103f7ee8b565c818b2 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,9 +376,9 @@ 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::Mcp::Server mount ::API::Notes mount ::API::NotificationSettings mount ::API::ProjectEvents diff --git a/lib/api/issues.rb b/lib/api/issues.rb index b285fe2a4242881c3d19bc77ca4905c3b6e46496..14d7d99d88d45b8d715635853afbb892e5a75175 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, 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 new file mode 100644 index 0000000000000000000000000000000000000000..a2ddb5374c36d255591b8bdb6aa0ba6f1961fe22 --- /dev/null +++ b/lib/api/mcp/handlers/call_tool.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module API + module Mcp + module Handlers + # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#calltoolrequest + class CallTool + def initialize(manager) + @manager = manager + end + + def invoke(request, params) + tool = find_tool!(params[:name]) + tool.execute(request, params) + end + + private + + attr_reader :manager + + def find_tool!(name) + tool = manager.tools[name] + raise ArgumentError, 'name is unsupported' unless tool + + tool + end + end + end + end +end diff --git a/lib/api/mcp/handlers/list_tools.rb b/lib/api/mcp/handlers/list_tools.rb new file mode 100644 index 0000000000000000000000000000000000000000..aa68399358b254e33c65cd76ac1c34cdbc7f555f --- /dev/null +++ b/lib/api/mcp/handlers/list_tools.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module API + module Mcp + module Handlers + # See: https://modelcontextprotocol.io/specification/2025-06-18/schema#listtoolsrequest + class ListTools + def initialize(manager) + @manager = manager + end + + def invoke + tools = manager.tools.map do |name, tool| + { + name: name, + description: tool.description, + inputSchema: tool.input_schema + } + end + + { tools: tools } + end + + private + + attr_reader :manager + end + end + end +end diff --git a/lib/api/mcp/server.rb b/lib/api/mcp/server.rb new file mode 100644 index 0000000000000000000000000000000000000000..ba9d5a919886360311820c45619c68c5a0115793 --- /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_manager, ::Mcp::Tools::Manager.new + 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_manager)).invoke(request, params[:params]) + when 'tools/list' + Handlers::ListTools.new(namespace_setting(:mcp_manager)).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 446d399121f7a0bda79a126f7ba0d80d328027f3..e5e81e71f27940f570ea772f5aaa4dc55f965a16 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, 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 0000000000000000000000000000000000000000..546653be8ce297c869ecf4a1a806e6bb7fa3cb8b --- /dev/null +++ b/spec/requests/api/mcp/handlers/call_tool_spec.rb @@ -0,0 +1,131 @@ +# 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']['structuredContent']['title']).to eq(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') + expect(json_response['result']['structuredContent']).to eq({ + "error" => { "message" => "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']['structuredContent']['title']).to eq(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 0000000000000000000000000000000000000000..cde78355c0f9a61b23f014812a00f0f2f4514c6e --- /dev/null +++ b/spec/requests/api/mcp/handlers/list_tools_spec.rb @@ -0,0 +1,71 @@ +# 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" + } + }, + { + "name" => "get_mcp_server_version", + "description" => "Get the current version of MCP server.", + "inputSchema" => { + "properties" => {}, + "required" => [], + "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 0000000000000000000000000000000000000000..f9fc27611e996b6daa13d1158c8353bb9df4bd2b --- /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 diff --git a/spec/requests/oauth/protected_resource_metadata_controller_spec.rb b/spec/requests/oauth/protected_resource_metadata_controller_spec.rb index 22c7a3ce835fa2258498594b34fc115e0cef2404..791ba5b11e4d0281dd92fbf0fa8f914938e829ae 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 new file mode 100644 index 0000000000000000000000000000000000000000..5e52e4e4578e27ebee01ed96aef4755920780d7b --- /dev/null +++ b/spec/services/mcp/tools/api_tool_spec.rb @@ -0,0 +1,338 @@ +# 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, + request_method: 'POST', + 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, 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(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 + + 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(request_env[Rack::REQUEST_METHOD]).to eq('POST') + 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, {}, [{ '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(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 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, {}, [{ 'result' => 'success' }.to_json]]) + 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 + 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 '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 0000000000000000000000000000000000000000..c6a36c839ba4dc5bdc8b84e881a3411287f595f5 --- /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