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.
This commit is contained in:
Jan Sandbrink
2026-02-10 10:16:54 +01:00
parent 40b4dc407e
commit 1279c0dc2c
6 changed files with 569 additions and 1 deletions
+2
View File
@@ -34,6 +34,8 @@ module McpTools
[
McpTools::ListStatuses,
McpTools::ListTypes,
McpTools::SearchPortfolios,
McpTools::SearchPrograms,
McpTools::SearchProjects,
McpTools::SearchWorkPackages
]
@@ -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
+76
View File
@@ -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
+1 -1
View File
@@ -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." }
}
)
@@ -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
@@ -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