mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user