diff --git a/app/services/mcp_resources.rb b/app/services/mcp_resources.rb index c8842a95104..8d8f6d58172 100644 --- a/app/services/mcp_resources.rb +++ b/app/services/mcp_resources.rb @@ -60,17 +60,21 @@ module McpResources end def read_resource(uri) - resource_class = enabled.find { |r| r.uri == uri || r.uri_template&.match?(uri) } - content = resource_class&.read(uri) + content = read_resource_content(uri) return [] if content.nil? [ - { - uri: uri, - mimeType: "application/json", - text: content.to_json - } + format_json_resource(uri, content) ] end + + def read_resource_content(uri, resources_considered: enabled) + resource_class = resources_considered.find { |r| r.uri == uri || r.uri_template&.match?(uri) } + resource_class&.read(uri) + end + + def format_json_resource(uri, content) + { uri:, mimeType: "application/json", text: content.to_json } + end end end diff --git a/app/services/mcp_tools.rb b/app/services/mcp_tools.rb index fde4d6775dc..878b169c01e 100644 --- a/app/services/mcp_tools.rb +++ b/app/services/mcp_tools.rb @@ -32,6 +32,8 @@ module McpTools class << self def all [ + McpTools::ListStatuses, + McpTools::ListTypes, McpTools::SearchProjects, McpTools::SearchWorkPackages ] diff --git a/app/services/mcp_tools/base.rb b/app/services/mcp_tools/base.rb index 0c7962501a8..de71ac95c20 100644 --- a/app/services/mcp_tools/base.rb +++ b/app/services/mcp_tools/base.rb @@ -113,7 +113,7 @@ module McpTools def read_annotations # Initialize default annotations, if none are present - annotations(read_only: false) if @annotations.nil? + annotations(read_only: false, destructive: true, idempotent: false) if @annotations.nil? @annotations end @@ -151,7 +151,7 @@ module McpTools validate_root_output_schema!(@tool_context.output_schema) end - MCP::Tool::Response.new([{ type: "text", text: result.to_json }], structured_content: result) + format_result(result) end private @@ -161,6 +161,10 @@ module McpTools raise NotImplemented, "#{self.class} needs to implement #call method" end + def format_result(result) + MCP::Tool::Response.new([{ type: "text", text: result.to_json }], structured_content: result) + end + def current_user @server_context[:current_user] end diff --git a/app/services/mcp_tools/list_statuses.rb b/app/services/mcp_tools/list_statuses.rb new file mode 100644 index 00000000000..de681ab1f01 --- /dev/null +++ b/app/services/mcp_tools/list_statuses.rb @@ -0,0 +1,43 @@ +# 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 ListStatuses < ResourceProxyTool + default_title "List statuses" + default_description "Lists all work package statuses available on this OpenProject instance. " \ + "Also available as an MCP resource." + + name "list_statuses" + + resource McpResources::StatusList + resource_schema "status_collection_model" + resource_annotations + end +end diff --git a/app/services/mcp_tools/list_types.rb b/app/services/mcp_tools/list_types.rb new file mode 100644 index 00000000000..d3570b517bb --- /dev/null +++ b/app/services/mcp_tools/list_types.rb @@ -0,0 +1,42 @@ +# 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 ListTypes < ResourceProxyTool + default_title "List types" + default_description "Lists all work package types available on this OpenProject instance. Also available as an MCP resource." + + name "list_types" + + resource McpResources::TypeList + resource_schema "types_model" + resource_annotations + end +end diff --git a/app/services/mcp_tools/resource_proxy_tool.rb b/app/services/mcp_tools/resource_proxy_tool.rb new file mode 100644 index 00000000000..8a21f395e10 --- /dev/null +++ b/app/services/mcp_tools/resource_proxy_tool.rb @@ -0,0 +1,64 @@ +# 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 + # A tool that effectively only serves a result that could've been obtained by fetching a resource + # Useful for clients that don't support resources, or ignore them, even if they could support them. + class ResourceProxyTool < Base + class << self + def resource(resource = nil) + @resource = resource if resource.present? + + @resource + end + + def resource_schema(schema_definition) + output_schema(JsonSchemaLoader.new.load(schema_definition)) + end + + def resource_annotations + annotations read_only: true, idempotent: true, destructive: false + end + end + + private + + def call + McpResources.read_resource_content(self.class.resource.uri, resources_considered: McpResources.all) + end + + def format_result(content) + MCP::Tool::Response.new( + [{ type: "resource", resource: McpResources.format_json_resource(self.class.resource.uri, content) }], + structured_content: content + ) + end + end +end diff --git a/spec/requests/mcp/mcp_tools/list_statuses_spec.rb b/spec/requests/mcp/mcp_tools/list_statuses_spec.rb new file mode 100644 index 00000000000..182fb9fe7ff --- /dev/null +++ b/spec/requests/mcp/mcp_tools/list_statuses_spec.rb @@ -0,0 +1,94 @@ +# 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::ListStatuses, 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(:user) } + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "tools/call", + params: { + name: "list_statuses", + arguments: call_args + } + } + end + let(:call_args) { {} } + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let!(:status_a) { create(:status) } + let!(:status_b) { create(:status) } + + 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 statuses" do + subject + expect(parsed_results.dig("structuredContent", "count")).to eq(2) + end + + it "responds with properly formatted statuses" do + subject + expect(parsed_results.fetch("structuredContent").to_json).to match_json_schema.from_docs("status_collection_model") + 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/list_types_spec.rb b/spec/requests/mcp/mcp_tools/list_types_spec.rb new file mode 100644 index 00000000000..d7fea3943b8 --- /dev/null +++ b/spec/requests/mcp/mcp_tools/list_types_spec.rb @@ -0,0 +1,94 @@ +# 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::ListTypes, 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(:user) } + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "tools/call", + params: { + name: "list_types", + arguments: call_args + } + } + end + let(:call_args) { {} } + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let!(:type_a) { create(:type) } + let!(:type_b) { create(:type) } + + 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 types" do + subject + expect(parsed_results.dig("structuredContent", "count")).to eq(2) + end + + it "responds with properly formatted types" do + subject + expect(parsed_results.fetch("structuredContent").to_json).to match_json_schema.from_docs("types_model") + 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