From 5aba04639c1f558a2ec6c9d2b55afc26d5752772 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Fri, 6 Feb 2026 15:45:01 +0100 Subject: [PATCH] [#71358] add search version tool - https://community.openproject.org/work_packages/71358 - add tool - add specs for the tool - slightly improve version model for the api --- app/services/mcp_tools.rb | 1 + app/services/mcp_tools/search_versions.rb | 83 +++++++++ .../components/schemas/version_model.yml | 82 ++++----- docs/api/apiv3/paths/versions.yml | 2 +- .../mcp/mcp_tools/search_versions_spec.rb | 166 ++++++++++++++++++ 5 files changed, 284 insertions(+), 50 deletions(-) create mode 100644 app/services/mcp_tools/search_versions.rb create mode 100644 spec/requests/mcp/mcp_tools/search_versions_spec.rb diff --git a/app/services/mcp_tools.rb b/app/services/mcp_tools.rb index 878b169c01e3..86284a347ac2 100644 --- a/app/services/mcp_tools.rb +++ b/app/services/mcp_tools.rb @@ -35,6 +35,7 @@ def all McpTools::ListStatuses, McpTools::ListTypes, McpTools::SearchProjects, + McpTools::SearchVersions, McpTools::SearchWorkPackages ] end diff --git a/app/services/mcp_tools/search_versions.rb b/app/services/mcp_tools/search_versions.rb new file mode 100644 index 000000000000..dbed6e9e0921 --- /dev/null +++ b/app/services/mcp_tools/search_versions.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module McpTools + class SearchVersions < Base + default_title "Search versions" + default_description "Search versions matching all of the passed input parameters. " \ + "Parameters not passed are ignored. Results are limited to a maximum " \ + "of #{page_size} versions. To get the rest of the results, call the tool again with a" \ + "page number of 2 or higher." + + name "search_versions" + annotations read_only: true, idempotent: true, destructive: false + enable_pagination + + filter :name, filter_class: Queries::Versions::Filters::NameFilter, operator: "~" + filter :sharing + + input_schema( + type: :object, + properties: { + name: { type: "string", description: "Name of the version. Accepts partial version names, not case-sensitive." }, + sharing: { + type: "string", + enum: Version::VERSION_SHARINGS, + description: `The indicator of how the version is shared between projects. This could be: +- 'none': if the version is only available in the defining project +- 'descendants': if the version is shared with the descendants of the defining project +- 'hierarchy': if the version is shared with the descendants or the ancestors of the defining project +- 'tree': if the version is shared with the root project of the defining project and all descendants of the root project +- 'system': if the version is shared globally` + } + } + ) + + output_schema( + type: :object, + required: ["items"], + properties: { + items: { + type: :array, + items: JsonSchemaLoader.new.load("version_model") + } + } + ) + + def call(page: nil, **filters) + filtered = apply_filters(Version.visible, filters) + versions = apply_pagination(filtered, page) + + { + items: versions.map { |v| API::V3::Versions::VersionRepresenter.create(v, current_user:) } + } + end + end +end diff --git a/docs/api/apiv3/components/schemas/version_model.yml b/docs/api/apiv3/components/schemas/version_model.yml index 2e2c7b3e8fbc..1e8d9304291c 100644 --- a/docs/api/apiv3/components/schemas/version_model.yml +++ b/docs/api/apiv3/components/schemas/version_model.yml @@ -1,25 +1,16 @@ # Schema: VersionModel --- type: object -required: -- name -- status -- sharing -- createdAt -- updatedAt properties: id: type: integer description: Version id - readOnly: true - exclusiveMinimum: 0 + minimum: 1 name: type: string description: Version name description: - allOf: - - "$ref": "./formattable.yml" - - {} + $ref: "./formattable.yml" startDate: type: - "string" @@ -40,62 +31,55 @@ properties: type: string format: date-time description: Time of creation - readOnly: true updatedAt: type: string format: date-time description: Time of the most recent change to the version - readOnly: true _links: type: object required: - - self - - availableInProjects + - self + - availableInProjects properties: update: allOf: - - "$ref": "./link.yml" - - description: |- - Form endpoint that aids in preparing and performing edits on the version - - # Conditions - - **Permission**: manage versions - readOnly: true + - $ref: "./link.yml" + - description: |- + Form endpoint that aids in preparing and performing edits on the version + + # Conditions + + **Permission**: manage versions updateImmediately: allOf: - - "$ref": "./link.yml" - - description: |- - Directly perform edits on the version - - # Conditions - - **Permission**: manage versions - readOnly: true + - $ref: "./link.yml" + - description: |- + Directly perform edits on the version + + # Conditions + + **Permission**: manage versions self: allOf: - - "$ref": "./link.yml" - - description: |- - This version - - **Resource**: Version - readOnly: true + - $ref: "./link.yml" + - description: |- + This version + + **Resource**: Version definingProject: allOf: - - "$ref": "./link.yml" - - description: |- - The workspace to which the version belongs - - **Resource**: Workspace - readOnly: true + - $ref: "./link.yml" + - description: |- + The workspace to which the version belongs + + **Resource**: Workspace availableInProjects: allOf: - - "$ref": "./link.yml" - - description: |- - Workspaces where this version can be used - - **Resource**: Workspace - readOnly: true + - $ref: "./link.yml" + - description: |- + Workspaces where this version can be used + + **Resource**: Workspace example: _links: self: diff --git a/docs/api/apiv3/paths/versions.yml b/docs/api/apiv3/paths/versions.yml index 175f00fd143b..17065020f5b0 100644 --- a/docs/api/apiv3/paths/versions.yml +++ b/docs/api/apiv3/paths/versions.yml @@ -9,7 +9,7 @@ get: + sharing: filters versions by how they are shared within the server (*none*, *descendants*, *hierarchy*, *tree*, *system*). + name: filters versions by their name. - example: '[{ "sharing": { "operator": "*", "values": ["system"] }" }]' + example: '[{ "sharing": { "operator": "=", "values": ["system"] } }]' in: query name: filters required: false diff --git a/spec/requests/mcp/mcp_tools/search_versions_spec.rb b/spec/requests/mcp/mcp_tools/search_versions_spec.rb new file mode 100644 index 000000000000..366c9df9592a --- /dev/null +++ b/spec/requests/mcp/mcp_tools/search_versions_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe McpTools::SearchVersions, with_flag: { mcp_server: true } do + subject do + header "Authorization", "Bearer #{access_token.plaintext_token}" + header "X-Authentication-Scheme", "Bearer" + header "Content-Type", "application/json" + post "/mcp", request_body.to_json + end + + let(:access_token) { create(:oauth_access_token, scopes: "mcp", resource_owner: user) } + let(:user) { create(:admin) } # using an admin, so that projects are visible + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "tools/call", + params: { + name: "search_versions", + arguments: call_args + } + } + end + let(:call_args) { {} } + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:project) { create(:project) } + + let!(:version_not_shared) { create(:version, project:, sharing: :none, name: "v1.0.1-alpha") } + let!(:version_shared_globally) { create(:version, project:, sharing: :system, name: "v1.1.0") } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + tool_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP response with structured content" + + it "finds all versions without filters" do + subject + expect(parsed_results.dig("structuredContent", "items").size).to eq(2) + end + + it "responds with properly formatted versions" do + subject + parsed_results.dig("structuredContent", "items").each do |version| + expect(version.to_json).to match_json_schema.from_docs("version_model") + end + end + + context "when passing an exact name" do + let(:call_args) { { name: "v1.0.1-alpha" } } + + it "finds the version" do + subject + expect(parsed_results.dig("structuredContent", "items").size).to eq(1) + end + end + + context "when passing a non-exact name" do + let(:call_args) { { name: "alpha" } } + + it "finds the version" do + subject + expect(parsed_results.dig("structuredContent", "items").size).to eq(1) + end + end + + context "when passing a version sharing strategy" do + let(:call_args) { { sharing: "system" } } + + it "finds the version" do + subject + expect(parsed_results.dig("structuredContent", "items").size).to eq(1) + end + + context "and when passing a version name" do + let(:call_args) { { sharing: "system", name: "v1" } } + + it "finds the version" do + subject + expect(parsed_results.dig("structuredContent", "items").size).to eq(1) + end + end + + context "and when passing a version name of a version with a different sharing strategy" do + let(:call_args) { { sharing: "system", name: "alpha" } } + + it "does not find the version" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_empty + end + end + end + + describe "pagination" do + let(:page_size) { 10 } + let(:overspilling_versions) { 5 } + let(:version_count) { page_size + overspilling_versions } + let(:call_args) { { name: "beta" } } + + before do + allow(described_class).to receive(:page_size).and_return(page_size) + + version_count.times do |idx| + create(:version, sharing: :none, name: "v1.2.#{idx}-beta") + end + end + + it "returns only results up to the page size" do + subject + expect(parsed_results.dig("structuredContent", "items").count).to eq(page_size) + end + + context "if another page is requested" do + let(:call_args) { { name: "beta", page: 2 } } + + it "returns the requested page" do + subject + expect(parsed_results.dig("structuredContent", "items").count).to eq(overspilling_versions) + end + end + end + end + + context "when the mcp_server enterprise feature is disabled" do + it "responds in a 404" do + subject + expect(last_response).to have_http_status(404) + end + end +end