From 1279c0dc2c8796b0f8703a3dd111dbe76bac84bf Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Tue, 10 Feb 2026 10:16:54 +0100 Subject: [PATCH] Add separate tools to search portfolios and programs We separated projects from portfolios and programs conceptually. While older APIs can return mixed results for backwards-compatibility purposes, the MCP API already purely returns projects from the search_projects tool, thus we need additional tools for results of other types. --- app/services/mcp_tools.rb | 2 + app/services/mcp_tools/search_portfolios.rb | 76 +++++++ app/services/mcp_tools/search_programs.rb | 76 +++++++ app/services/mcp_tools/search_projects.rb | 2 +- .../mcp/mcp_tools/search_portfolios_spec.rb | 207 ++++++++++++++++++ .../mcp/mcp_tools/search_programs_spec.rb | 207 ++++++++++++++++++ 6 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 app/services/mcp_tools/search_portfolios.rb create mode 100644 app/services/mcp_tools/search_programs.rb create mode 100644 spec/requests/mcp/mcp_tools/search_portfolios_spec.rb create mode 100644 spec/requests/mcp/mcp_tools/search_programs_spec.rb diff --git a/app/services/mcp_tools.rb b/app/services/mcp_tools.rb index 878b169c01e..45c0d700193 100644 --- a/app/services/mcp_tools.rb +++ b/app/services/mcp_tools.rb @@ -34,6 +34,8 @@ module McpTools [ McpTools::ListStatuses, McpTools::ListTypes, + McpTools::SearchPortfolios, + McpTools::SearchPrograms, McpTools::SearchProjects, McpTools::SearchWorkPackages ] diff --git a/app/services/mcp_tools/search_portfolios.rb b/app/services/mcp_tools/search_portfolios.rb new file mode 100644 index 00000000000..ae5504f4b8d --- /dev/null +++ b/app/services/mcp_tools/search_portfolios.rb @@ -0,0 +1,76 @@ +# 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 SearchPortfolios < Base + default_title "Search portfolios" + default_description "Search portfolios matching all of the passed input parameters. " \ + "Parameters not passed are ignored. Results are limited to a maximum " \ + "of #{page_size} portfolios. To get the rest of the results, call the tool again with a" \ + "page number of 2 or higher." + + name "search_portfolios" + annotations read_only: true, idempotent: true, destructive: false + enable_pagination + + filter :name, filter_class: Queries::Projects::Filters::NameFilter, operator: "~" + filter :identifier + filter :status_code + + input_schema( + type: :object, + properties: { + name: { type: "string", description: "Name of the portfolio. Accepts partial names, not case-sensitive." }, + identifier: { type: "string", description: "Portfolio identifier. Case-sensitive, matching exactly." }, + status_code: { type: "string", enum: Project.status_codes.keys, description: "The portfolio status." } + } + ) + + output_schema( + type: :object, + required: ["items"], + properties: { + items: { + type: :array, + items: JsonSchemaLoader.new.load("portfolio_model") + } + } + ) + + def call(page: nil, **filters) + filtered = apply_filters(Project.portfolio.visible, filters) + portfolios = apply_pagination(filtered, page) + + { + items: portfolios.map { |p| API::V3::Projects::ProjectRepresenter.create(p, current_user:) } + } + end + end +end diff --git a/app/services/mcp_tools/search_programs.rb b/app/services/mcp_tools/search_programs.rb new file mode 100644 index 00000000000..d66a8121271 --- /dev/null +++ b/app/services/mcp_tools/search_programs.rb @@ -0,0 +1,76 @@ +# 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 SearchPrograms < Base + default_title "Search programs" + default_description "Search programs matching all of the passed input parameters. " \ + "Parameters not passed are ignored. Results are limited to a maximum " \ + "of #{page_size} programs. To get the rest of the results, call the tool again with a" \ + "page number of 2 or higher." + + name "search_programs" + annotations read_only: true, idempotent: true, destructive: false + enable_pagination + + filter :name, filter_class: Queries::Projects::Filters::NameFilter, operator: "~" + filter :identifier + filter :status_code + + input_schema( + type: :object, + properties: { + name: { type: "string", description: "Name of the program. Accepts partial names, not case-sensitive." }, + identifier: { type: "string", description: "Program identifier. Case-sensitive, matching exactly." }, + status_code: { type: "string", enum: Project.status_codes.keys, description: "The program status." } + } + ) + + output_schema( + type: :object, + required: ["items"], + properties: { + items: { + type: :array, + items: JsonSchemaLoader.new.load("program_model") + } + } + ) + + def call(page: nil, **filters) + filtered = apply_filters(Project.program.visible, filters) + programs = apply_pagination(filtered, page) + + { + items: programs.map { |p| API::V3::Projects::ProjectRepresenter.create(p, current_user:) } + } + end + end +end diff --git a/app/services/mcp_tools/search_projects.rb b/app/services/mcp_tools/search_projects.rb index 5fb22c75464..4801b79dcb8 100644 --- a/app/services/mcp_tools/search_projects.rb +++ b/app/services/mcp_tools/search_projects.rb @@ -48,7 +48,7 @@ module McpTools type: :object, properties: { name: { type: "string", description: "Name of the project. Accepts partial project names, not case-sensitive." }, - identifier: { type: "string", description: "Project indentifier. Case-sensitive, matching exactly." }, + identifier: { type: "string", description: "Project identifier. Case-sensitive, matching exactly." }, status_code: { type: "string", enum: Project.status_codes.keys, description: "The project status." } } ) diff --git a/spec/requests/mcp/mcp_tools/search_portfolios_spec.rb b/spec/requests/mcp/mcp_tools/search_portfolios_spec.rb new file mode 100644 index 00000000000..89871903eb1 --- /dev/null +++ b/spec/requests/mcp/mcp_tools/search_portfolios_spec.rb @@ -0,0 +1,207 @@ +# 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::SearchPortfolios, 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 portfolios are visible + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "tools/call", + params: { + name: "search_portfolios", + arguments: call_args + } + } + end + let(:call_args) { {} } + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let!(:portfolio_a) { create(:portfolio, identifier: "abc", name: "The ABC Portfolio", status_code: :on_track) } + let!(:portfolio_b) { create(:portfolio, identifier: "def", name: "The DEF Portfolio", status_code: :off_track) } + let!(:project) { create(:project, identifier: "ghi", name: "The unrelated Project", status_code: :on_track) } + + 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 portfolios without filters" do + subject + expect(parsed_results.dig("structuredContent", "items").size).to eq(2) + end + + it "responds with properly formatted portfolios" do + subject + parsed_results.dig("structuredContent", "items").each do |portfolio| + expect(portfolio.to_json).to match_json_schema.from_docs("portfolio_model") + end + end + + context "when passing an exact identifier" do + let(:call_args) { { identifier: "abc" } } + + it "finds the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + context "when passing a non-exact identifier" do + let(:call_args) { { identifier: "Abc" } } + + it "does not find the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_empty + end + end + + context "when passing an exact name" do + let(:call_args) { { name: "The ABC Portfolio" } } + + it "finds the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + describe "pagination" do + let(:page_size) { 10 } + let(:overspilling_portfolios) { 5 } + let(:portfolio_count) { page_size + overspilling_portfolios } + let(:call_args) { { name: "Death Star" } } + + before do + allow(described_class).to receive(:page_size).and_return(page_size) + + portfolio_count.times do |idx| + create(:portfolio, + identifier: "p#{idx}", + name: "Death Star construction phase #{idx}", + status_code: :on_track) + 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: "Death Star", page: 2 } } + + it "returns the requested page" do + subject + expect(parsed_results.dig("structuredContent", "items").count).to eq(overspilling_portfolios) + end + end + end + + context "when passing a non-exact name" do + let(:call_args) { { name: "The abc" } } + + it "finds the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + context "when passing a portfolio status" do + let(:call_args) { { status_code: "on_track" } } + + it "finds the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + + context "and when passing a portfolio identifier" do + let(:call_args) { { status_code: "on_track", identifier: "abc" } } + + it "finds the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + context "and when passing the portfolio identifier of a portfolio in a different status" do + let(:call_args) { { status_code: "on_track", identifier: "def" } } + + it "does not find the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_empty + end + end + end + + context "when passing an invalid portfolio status" do + let(:call_args) { { status_code: "blubb" } } + + it_behaves_like "MCP error response" + end + + context "when user can't see portfolios" do + let(:user) { create(:user) } + + it "does not find the portfolio" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_empty + end + end + + context "when the tool is disabled via configuration" do + let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP error response" + 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 diff --git a/spec/requests/mcp/mcp_tools/search_programs_spec.rb b/spec/requests/mcp/mcp_tools/search_programs_spec.rb new file mode 100644 index 00000000000..60832a304fa --- /dev/null +++ b/spec/requests/mcp/mcp_tools/search_programs_spec.rb @@ -0,0 +1,207 @@ +# 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::SearchPrograms, 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 programs are visible + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "tools/call", + params: { + name: "search_programs", + arguments: call_args + } + } + end + let(:call_args) { {} } + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let!(:program_a) { create(:program, identifier: "abc", name: "The ABC Program", status_code: :on_track) } + let!(:program_b) { create(:program, identifier: "def", name: "The DEF Program", status_code: :off_track) } + let!(:project) { create(:project, identifier: "ghi", name: "The unrelated Project", status_code: :on_track) } + + 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 programs without filters" do + subject + expect(parsed_results.dig("structuredContent", "items").size).to eq(2) + end + + it "responds with properly formatted programs" do + subject + parsed_results.dig("structuredContent", "items").each do |program| + expect(program.to_json).to match_json_schema.from_docs("program_model") + end + end + + context "when passing an exact identifier" do + let(:call_args) { { identifier: "abc" } } + + it "finds the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + context "when passing a non-exact identifier" do + let(:call_args) { { identifier: "Abc" } } + + it "does not find the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_empty + end + end + + context "when passing an exact name" do + let(:call_args) { { name: "The ABC Program" } } + + it "finds the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + describe "pagination" do + let(:page_size) { 10 } + let(:overspilling_programs) { 5 } + let(:program_count) { page_size + overspilling_programs } + let(:call_args) { { name: "Death Star" } } + + before do + allow(described_class).to receive(:page_size).and_return(page_size) + + program_count.times do |idx| + create(:program, + identifier: "p#{idx}", + name: "Death Star construction phase #{idx}", + status_code: :on_track) + 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: "Death Star", page: 2 } } + + it "returns the requested page" do + subject + expect(parsed_results.dig("structuredContent", "items").count).to eq(overspilling_programs) + end + end + end + + context "when passing a non-exact name" do + let(:call_args) { { name: "The abc" } } + + it "finds the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + context "when passing a program status" do + let(:call_args) { { status_code: "on_track" } } + + it "finds the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + + context "and when passing a program identifier" do + let(:call_args) { { status_code: "on_track", identifier: "abc" } } + + it "finds the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_present + end + end + + context "and when passing the program identifier of a program in a different status" do + let(:call_args) { { status_code: "on_track", identifier: "def" } } + + it "does not find the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_empty + end + end + end + + context "when passing an invalid program status" do + let(:call_args) { { status_code: "blubb" } } + + it_behaves_like "MCP error response" + end + + context "when user can't see programs" do + let(:user) { create(:user) } + + it "does not find the program" do + subject + expect(parsed_results.dig("structuredContent", "items")).to be_empty + end + end + + context "when the tool is disabled via configuration" do + let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP error response" + 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