diff --git a/app/seeders/mcp_configuration_seeder.rb b/app/seeders/mcp_configuration_seeder.rb index 95f9458bc688..4ff17cd7b64e 100644 --- a/app/seeders/mcp_configuration_seeder.rb +++ b/app/seeders/mcp_configuration_seeder.rb @@ -31,11 +31,11 @@ class McpConfigurationSeeder < Seeder def seed_data! seed_server_config if server_missing? - seed_tool_configs + seed_resource_and_tool_configs end def applicable? - server_missing? || tools_missing? + server_missing? || tools_missing? || resources_missing? end def not_applicable_message @@ -53,8 +53,8 @@ def seed_server_config ) end - def seed_tool_configs - McpTools.all.each do |thing| # rubocop:disable Rails/FindEach + def seed_resource_and_tool_configs + (McpTools.all + McpResources.all).each do |thing| next if McpConfiguration.find_by(identifier: thing.qualified_name) McpConfiguration.create!( @@ -73,4 +73,8 @@ def server_missing? def tools_missing? (McpTools.all.map(&:qualified_name) - McpConfiguration.pluck(:identifier)).any? end + + def resources_missing? + (McpResources.all.map(&:qualified_name) - McpConfiguration.pluck(:identifier)).any? + end end diff --git a/app/services/mcp_resources.rb b/app/services/mcp_resources.rb new file mode 100644 index 000000000000..c8842a95104c --- /dev/null +++ b/app/services/mcp_resources.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 McpResources + class << self + def all + [ + Project, + Status, + StatusList, + Type, + TypeList, + User, + Version, + WorkPackage + ] + end + + def enabled + McpConfiguration.where(enabled: true).pluck(:identifier).filter_map { |name| resources_by_name[name] } + end + + def resources_by_name + @resources_by_name ||= all.index_by(&:qualified_name) + end + + def enabled_resources + enabled.select(&:uri) + end + + def enabled_resource_templates + enabled.select(&:uri_template) + end + + def read_resource(uri) + resource_class = enabled.find { |r| r.uri == uri || r.uri_template&.match?(uri) } + content = resource_class&.read(uri) + return [] if content.nil? + + [ + { + uri: uri, + mimeType: "application/json", + text: content.to_json + } + ] + end + end +end diff --git a/app/services/mcp_resources/base.rb b/app/services/mcp_resources/base.rb new file mode 100644 index 000000000000..09f0471f8d02 --- /dev/null +++ b/app/services/mcp_resources/base.rb @@ -0,0 +1,114 @@ +# 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 McpResources + class Base + include APIV3Helper + + class << self + def qualified_name + "resources/#{name}" + end + + def default_title(title = nil) + @default_title = title if title.present? + + @default_title + end + + def default_description(description = nil) + @default_description = description if description.present? + + @default_description + end + + def name(name = nil) + @name = name if name.present? + + @name + end + + def uri(suffix = nil) + @uri_suffix = suffix if suffix.present? + return nil if @uri_suffix.nil? + + "#{Setting.protocol}://#{Setting.host_name}#{@uri_suffix}" + end + + def uri_template(suffix = nil) + @template_suffix = suffix if suffix.present? + return nil if @template_suffix.nil? + + UriTemplate.new("#{Setting.protocol}://#{Setting.host_name}#{@template_suffix}") + end + + def resource + raise ArgumentError, "#{self.class.name} can't be used as resource, uri is blank" if uri.blank? + + config = McpConfiguration.find_by(identifier: qualified_name) + return nil if config.nil? + + MCP::Resource.new( + uri:, + name:, + title: config.title, + description: config.description, + mime_type: "application/json" + ) + end + + def resource_template + raise ArgumentError, "#{self.class.name} can't be used as resource_template, uri_template is blank" if uri_template.blank? + + config = McpConfiguration.find_by(identifier: qualified_name) + return nil if config.nil? + + MCP::ResourceTemplate.new( + uri_template:, + name:, + title: config.title, + description: config.description, + mime_type: "application/json" + ) + end + + def read(uri) + params = uri_template&.parse(uri) || {} + new.read(**params) + end + end + + def current_user = ::User.current + + def read(**) + raise NotImplemented, "#{self.class} needs to implement #read method" + end + end +end diff --git a/app/services/mcp_resources/project.rb b/app/services/mcp_resources/project.rb new file mode 100644 index 000000000000..e130fe632456 --- /dev/null +++ b/app/services/mcp_resources/project.rb @@ -0,0 +1,46 @@ +# 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 McpResources + class Project < Base + name "project" + uri_template "/api/v3/projects/{id}" + + default_title "Project" + default_description "Access projects of this OpenProject instance." + + def read(id:) + project = ::Project.visible(current_user).find_by(id:) + return nil if project.nil? + + API::V3::Projects::ProjectRepresenter.create(project, current_user:) + end + end +end diff --git a/app/services/mcp_resources/status.rb b/app/services/mcp_resources/status.rb new file mode 100644 index 000000000000..28b9316fb3c3 --- /dev/null +++ b/app/services/mcp_resources/status.rb @@ -0,0 +1,46 @@ +# 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 McpResources + class Status < Base + name "status" + uri_template "/api/v3/statuses/{id}" + + default_title "Work Package Status" + default_description "Access work package statuses of this OpenProject instance." + + def read(id:) + status = ::Status.find_by(id:) + return nil if status.nil? + + API::V3::Statuses::StatusRepresenter.new(status, current_user:) + end + end +end diff --git a/app/services/mcp_resources/status_list.rb b/app/services/mcp_resources/status_list.rb new file mode 100644 index 000000000000..be8c6e198262 --- /dev/null +++ b/app/services/mcp_resources/status_list.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 McpResources + class StatusList < Base + name "status_list" + uri "/api/v3/statuses" + + default_title "Work Package Statuses List" + default_description "A list of all work package statuses configured in this OpenProject instance." + + def read + API::V3::Statuses::StatusCollectionRepresenter.new(::Status.all, self_link: api_v3_paths.statuses, current_user:) + end + end +end diff --git a/app/services/mcp_resources/type.rb b/app/services/mcp_resources/type.rb new file mode 100644 index 000000000000..2e7f6f5c8f44 --- /dev/null +++ b/app/services/mcp_resources/type.rb @@ -0,0 +1,46 @@ +# 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 McpResources + class Type < Base + name "type" + uri_template "/api/v3/types/{id}" + + default_title "Work Package Type" + default_description "Access work package types of this OpenProject instance." + + def read(id:) + type = ::Type.find_by(id:) + return nil if type.nil? + + API::V3::Types::TypeRepresenter.new(type, current_user:) + end + end +end diff --git a/app/services/mcp_resources/type_list.rb b/app/services/mcp_resources/type_list.rb new file mode 100644 index 000000000000..cbe4a1693d16 --- /dev/null +++ b/app/services/mcp_resources/type_list.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 McpResources + class TypeList < Base + name "type_list" + uri "/api/v3/types" + + default_title "Work Package Types List" + default_description "A list of all work package types configured in this OpenProject instance." + + def read + API::V3::Types::TypeCollectionRepresenter.new(::Type.includes(:color).all, self_link: api_v3_paths.types, current_user:) + end + end +end diff --git a/app/services/mcp_resources/user.rb b/app/services/mcp_resources/user.rb new file mode 100644 index 000000000000..b9f522f10ea1 --- /dev/null +++ b/app/services/mcp_resources/user.rb @@ -0,0 +1,46 @@ +# 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 McpResources + class User < Base + name "user" + uri_template "/api/v3/users/{id}" + + default_title "User" + default_description "Access users of this OpenProject instance." + + def read(id:) + user = ::User.visible(current_user).find_by_unique(id) # rubocop:disable Rails/DynamicFindBy + return nil if user.nil? + + API::V3::Users::UserRepresenter.create(user, current_user:) + end + end +end diff --git a/app/services/mcp_resources/version.rb b/app/services/mcp_resources/version.rb new file mode 100644 index 000000000000..c2b1c8cedcf5 --- /dev/null +++ b/app/services/mcp_resources/version.rb @@ -0,0 +1,46 @@ +# 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 McpResources + class Version < Base + name "version" + uri_template "/api/v3/versions/{id}" + + default_title "Work Package Version" + default_description "Access work package versions of this OpenProject instance." + + def read(id:) + version = ::Version.visible.find_by(id:) + return nil if version.nil? + + API::V3::Versions::VersionRepresenter.create(version, current_user:) + end + end +end diff --git a/app/services/mcp_resources/work_package.rb b/app/services/mcp_resources/work_package.rb new file mode 100644 index 000000000000..58fe41ca792e --- /dev/null +++ b/app/services/mcp_resources/work_package.rb @@ -0,0 +1,47 @@ +# 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 McpResources + class WorkPackage < Base + name "work_package" + uri_template "/api/v3/work_packages/{id}" + + default_title "Work Package" + default_description "Access work packages of this OpenProject instance." + + def read(id:) + work_package = ::WorkPackage.find_by(id:) + return nil if work_package.nil? + return nil unless current_user.allowed_in_work_package?(:view_work_packages, work_package) + + API::V3::WorkPackages::WorkPackageRepresenter.create(work_package, current_user:, embed_links: true) + end + end +end diff --git a/app/services/mcp_tools.rb b/app/services/mcp_tools.rb index f0887cb059b8..43041e0e2379 100644 --- a/app/services/mcp_tools.rb +++ b/app/services/mcp_tools.rb @@ -41,7 +41,7 @@ def enabled end def tools_by_name - all.index_by(&:qualified_name) + @tools_by_name ||= all.index_by(&:qualified_name) end end end diff --git a/app/services/uri_template.rb b/app/services/uri_template.rb new file mode 100644 index 000000000000..d95e5019acb5 --- /dev/null +++ b/app/services/uri_template.rb @@ -0,0 +1,58 @@ +# 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. +#++ + +# A simple implementation of the essential parts of https://datatracker.ietf.org/doc/html/rfc6570 +# So far we only need it to parse simple URI templates we defined ourselves and match them, so this is what's implemented. +# If we start needing more, we either need to add more or start double checking existing solutions. +class UriTemplate + def initialize(template_string) + raise ArgumentError, "template_string can't be nil" if template_string.nil? + + @template_string = template_string + @variables = template_string.scan(/\{(\w+)\}/).flatten + matcher_string = "^#{Regexp.escape(template_string)}$" + @variables.each { |v| matcher_string.gsub!(/\\\{#{v}\\\}/, "(?<#{v}>[\\w-]+)") } + @matcher = Regexp.new(matcher_string) + end + + delegate :match?, to: :@matcher + delegate :as_json, to: :to_s + + def parse(uri) + match = @matcher.match(uri) + return nil if match.nil? + + @variables.to_h { |v| [v.to_sym, match[v]] } + end + + def to_s + @template_string + end +end diff --git a/docs/api/apiv3/components/schemas/type_model.yml b/docs/api/apiv3/components/schemas/type_model.yml index 4f428c5feba5..800618697684 100644 --- a/docs/api/apiv3/components/schemas/type_model.yml +++ b/docs/api/apiv3/components/schemas/type_model.yml @@ -21,7 +21,9 @@ properties: description: Type name readOnly: true color: - type: string + type: + - "string" + - "null" description: The color used to represent this type readOnly: true position: diff --git a/lib/api/mcp.rb b/lib/api/mcp.rb index d508419296db..c98b2d174425 100644 --- a/lib/api/mcp.rb +++ b/lib/api/mcp.rb @@ -57,9 +57,13 @@ def server_config # description: server_config.description, # not yet supported by mcp gem version: "1.0.0", tools: McpTools.enabled.map(&:tool), + resources: McpResources.enabled_resources.map(&:resource), + resource_templates: McpResources.enabled_resource_templates.map(&:resource_template), server_context: { user_id: User.current.id } ) + server.resources_read_handler { |params| McpResources.read_resource(params[:uri]) } + status 200 # HACK: Grape is JSON-serializing whatever we return here, but handle_json already returns serialized JSON diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index c2a0af0e28f2..704fea146ff2 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -535,8 +535,7 @@ def self_v3_path(*) } else { - href: nil, - title: nil + href: nil } end end diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index 5e72572c4b24..5de3c09d3590 100644 --- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -1481,40 +1481,36 @@ work_package.project_phase_definition = nil end - it_behaves_like "has a titled link" do + it_behaves_like "has an untitled link" do let(:link) { "projectPhaseDefinition" } let(:href) { nil } - let(:title) { nil } end end context "with the phase not existing in the project" do let(:project_phases) { [other_project_phase] } - it_behaves_like "has a titled link" do + it_behaves_like "has an untitled link" do let(:link) { "projectPhaseDefinition" } let(:href) { nil } - let(:title) { nil } end end context "with the phase being inactive in the project" do let(:project_phase) { build_stubbed(:project_phase, active: false, definition: project_phase_definition) } - it_behaves_like "has a titled link" do + it_behaves_like "has an untitled link" do let(:link) { "projectPhaseDefinition" } let(:href) { nil } - let(:title) { nil } end end context "without the user being allowed to see the reference" do let(:permissions) { all_permissions - [:view_project_phases] } - it_behaves_like "has a titled link" do + it_behaves_like "has an untitled link" do let(:link) { "projectPhaseDefinition" } let(:href) { nil } - let(:title) { nil } end end end diff --git a/spec/requests/api/v3/support/mcp_examples.rb b/spec/requests/api/v3/support/mcp_examples.rb index ce41ccc33c9b..53930887361f 100644 --- a/spec/requests/api/v3/support/mcp_examples.rb +++ b/spec/requests/api/v3/support/mcp_examples.rb @@ -79,7 +79,52 @@ it "fulfills the schema of a structured MCP response" do subject - expect(last_response.body).to match_json_schema(json_rpc_response_schema) + expect(last_response.body).to match_json_schema(result_schema) + end +end + +RSpec.shared_examples_for "MCP text resource response" do + let(:result_schema) do + { + required: %w[result], + properties: { + result: { + type: "object", + required: %w[contents], + properties: { + contents: { + type: "array", + items: { + type: "object", + required: %w[uri text], + properties: { + uri: { type: "string" }, + mimeType: { type: "string" }, + text: { type: "string" } + } + } + } + } + } + } + } + end + + include_context "MCP result response" + + it "fulfills the schema of a text resource" do + subject + expect(last_response.body).to match_json_schema(result_schema) + end +end + +RSpec.shared_examples_for "MCP empty resource response" do + include_context "MCP text resource response" + + it "has no contents" do + subject + parsed = JSON.parse(last_response.body) + expect(parsed.dig("result", "contents")).to be_empty end end diff --git a/spec/requests/mcp/mcp_resources/project_spec.rb b/spec/requests/mcp/mcp_resources/project_spec.rb new file mode 100644 index 000000000000..314879c9b62f --- /dev/null +++ b/spec/requests/mcp/mcp_resources/project_spec.rb @@ -0,0 +1,100 @@ +# 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 McpResources::Project, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { uri: resource_uri } + } + end + let(:resource_uri) { "http://test.host/api/v3/projects/#{project.id}" } + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:project) { create(:project) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted project" do + subject + text_content = parsed_results.fetch("contents").first + wp = text_content.fetch("text") + expect(wp).to match_json_schema.from_docs("project_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a non-existing project" do + let(:resource_uri) { "http://test.host/api/v3/projects/#{project.id + 1}" } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a project not visible to the user" do + let(:user) { create(:user) } + + it_behaves_like "MCP empty resource 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_resources/status_list_spec.rb b/spec/requests/mcp/mcp_resources/status_list_spec.rb new file mode 100644 index 000000000000..94ea2697b148 --- /dev/null +++ b/spec/requests/mcp/mcp_resources/status_list_spec.rb @@ -0,0 +1,89 @@ +# 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 McpResources::StatusList, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { + uri: "http://test.host/api/v3/statuses" + } + } + end + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let!(:status) { create(:status) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted status list" do + subject + text_content = parsed_results.fetch("contents").first + statuses = text_content.fetch("text") + expect(statuses).to match_json_schema.from_docs("status_collection_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource 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_resources/status_spec.rb b/spec/requests/mcp/mcp_resources/status_spec.rb new file mode 100644 index 000000000000..22661eb6f8fa --- /dev/null +++ b/spec/requests/mcp/mcp_resources/status_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 McpResources::Status, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { uri: resource_uri } + } + end + let(:resource_uri) { "http://test.host/api/v3/statuses/#{status.id}" } + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:status) { create(:status) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted status" do + subject + text_content = parsed_results.fetch("contents").first + status = text_content.fetch("text") + expect(status).to match_json_schema.from_docs("status_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a non-existing status" do + let(:resource_uri) { "http://test.host/api/v3/statuses/#{status.id + 1}" } + + it_behaves_like "MCP empty resource 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_resources/type_list_spec.rb b/spec/requests/mcp/mcp_resources/type_list_spec.rb new file mode 100644 index 000000000000..64daa452140d --- /dev/null +++ b/spec/requests/mcp/mcp_resources/type_list_spec.rb @@ -0,0 +1,89 @@ +# 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 McpResources::TypeList, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { + uri: "http://test.host/api/v3/types" + } + } + end + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let!(:type) { create(:type) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted type list" do + subject + text_content = parsed_results.fetch("contents").first + types = text_content.fetch("text") + expect(types).to match_json_schema.from_docs("types_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource 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_resources/type_spec.rb b/spec/requests/mcp/mcp_resources/type_spec.rb new file mode 100644 index 000000000000..c99038c3cdd8 --- /dev/null +++ b/spec/requests/mcp/mcp_resources/type_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 McpResources::Type, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { uri: resource_uri } + } + end + let(:resource_uri) { "http://test.host/api/v3/types/#{type.id}" } + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:type) { create(:type) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted type" do + subject + text_content = parsed_results.fetch("contents").first + type = text_content.fetch("text") + expect(type).to match_json_schema.from_docs("type_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a non-existing type" do + let(:resource_uri) { "http://test.host/api/v3/types/#{type.id + 1}" } + + it_behaves_like "MCP empty resource 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_resources/user_spec.rb b/spec/requests/mcp/mcp_resources/user_spec.rb new file mode 100644 index 000000000000..1a8ff3afd1c0 --- /dev/null +++ b/spec/requests/mcp/mcp_resources/user_spec.rb @@ -0,0 +1,100 @@ +# 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 McpResources::User, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { uri: resource_uri } + } + end + let(:resource_uri) { "http://test.host/api/v3/users/#{requested_user.id}" } + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:requested_user) { create(:user) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted user" do + subject + text_content = parsed_results.fetch("contents").first + user_json = text_content.fetch("text") + expect(user_json).to match_json_schema.from_docs("user_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a non-existing user" do + let(:resource_uri) { "http://test.host/api/v3/users/#{requested_user.id + 10}" } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a user not visible to the user" do + let(:user) { create(:user) } + + it_behaves_like "MCP empty resource 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_resources/version_spec.rb b/spec/requests/mcp/mcp_resources/version_spec.rb new file mode 100644 index 000000000000..ad75e06c0b69 --- /dev/null +++ b/spec/requests/mcp/mcp_resources/version_spec.rb @@ -0,0 +1,100 @@ +# 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 McpResources::Version, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { uri: resource_uri } + } + end + let(:resource_uri) { "http://test.host/api/v3/versions/#{version.id}" } + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:version) { create(:version) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted version" do + subject + text_content = parsed_results.fetch("contents").first + version = text_content.fetch("text") + expect(version).to match_json_schema.from_docs("version_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a non-existing version" do + let(:resource_uri) { "http://test.host/api/v3/versions/#{version.id + 1}" } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a version not visible to the user" do + let(:user) { create(:user) } + + it_behaves_like "MCP empty resource 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_resources/work_package_spec.rb b/spec/requests/mcp/mcp_resources/work_package_spec.rb new file mode 100644 index 000000000000..d3e82384fea2 --- /dev/null +++ b/spec/requests/mcp/mcp_resources/work_package_spec.rb @@ -0,0 +1,100 @@ +# 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 McpResources::WorkPackage, 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, to ensure visibility of everything + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/read", + params: { uri: resource_uri } + } + end + let(:resource_uri) { "http://test.host/api/v3/work_packages/#{work_package.id}" } + + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:work_package) { create(:work_package) } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } + + before do + server_config.save! + resource_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP text resource response" + + it "responds with a properly formatted work package" do + subject + text_content = parsed_results.fetch("contents").first + wp = text_content.fetch("text") + expect(wp).to match_json_schema.from_docs("work_package_model") + end + + context "when the resource is disabled via configuration" do + let(:resource_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a non-existing work package" do + let(:resource_uri) { "http://test.host/api/v3/work_packages/#{work_package.id + 1}" } + + it_behaves_like "MCP empty resource response" + end + + context "when requesting a work package not visible to the user" do + let(:user) { create(:user) } + + it_behaves_like "MCP empty resource 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/tools/search_project_spec.rb b/spec/requests/mcp/mcp_tools/search_project_spec.rb similarity index 95% rename from spec/requests/mcp/tools/search_project_spec.rb rename to spec/requests/mcp/mcp_tools/search_project_spec.rb index 2d73740bc61c..4c5d68dace30 100644 --- a/spec/requests/mcp/tools/search_project_spec.rb +++ b/spec/requests/mcp/mcp_tools/search_project_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe "MCP search_project tool", with_flag: { mcp_server: true } do +RSpec.describe McpTools::SearchProject, with_flag: { mcp_server: true } do subject do header "Authorization", "Bearer #{access_token.plaintext_token}" header "X-Authentication-Scheme", "Bearer" @@ -58,7 +58,7 @@ let!(:project_b) { create(:project, identifier: "def", name: "The DEF Project", status_code: :off_track) } let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } - let(:tool_config) { create(:mcp_configuration, identifier: McpTools::SearchProject.qualified_name) } + let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name) } before do server_config.save! @@ -149,7 +149,7 @@ end context "when the tool is disabled via configuration" do - let(:tool_config) { create(:mcp_configuration, identifier: McpTools::SearchProject.qualified_name, enabled: false) } + let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) } it_behaves_like "MCP error response" end diff --git a/spec/requests/mcp/resource_templates_list_spec.rb b/spec/requests/mcp/resource_templates_list_spec.rb new file mode 100644 index 000000000000..4b1621faddf7 --- /dev/null +++ b/spec/requests/mcp/resource_templates_list_spec.rb @@ -0,0 +1,135 @@ +# 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 "MCP resources/templates/list", 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") } + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/templates/list", + params: {} + } + end + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: McpResources::StatusList.qualified_name) } + let(:resource_template_config) { create(:mcp_configuration, identifier: McpResources::Status.qualified_name) } + + before do + server_config.save! + resource_config.save! + resource_template_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP result response" + + it "includes the status resource template" do + subject + + resource = parsed_results.fetch("resourceTemplates").find { |t| t.fetch("name") == "status" } + expect(resource).not_to be_nil + expect(resource.fetch("title")).to eq(resource_config.title) + expect(resource.fetch("description")).to eq(resource_config.description) + end + + it "returns a fully qualified uriTemplate" do + subject + + resource = parsed_results.fetch("resourceTemplates").find { |t| t.fetch("name") == "status" } + expect(resource.fetch("uriTemplate")).to eq("http://test.host/api/v3/statuses/{id}") + end + + it "does not include resources" do + subject + + resource = parsed_results.fetch("resourceTemplates").find { |t| t.fetch("name") == "status_list" } + expect(resource).to be_nil + end + + context "when not passing a Bearer token" do + subject do + header "X-Authentication-Scheme", "Bearer" + header "Content-Type", "application/json" + post "/mcp", request_body.to_json + end + + it_behaves_like "MCP unauthenticated response" + end + + context "when passing a Bearer token with a wrong scope" do + let(:access_token) { create(:oauth_access_token, scopes: "api_v3") } + + it_behaves_like "MCP unauthenticated response" + end + + context "when the MCP server is disabled via configuration" do + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server", enabled: false) } + + it "responds in a 404" do + subject + expect(last_response).to have_http_status(404) + end + end + + context "when the status resource template is disabled" do + let(:resource_template_config) do + create(:mcp_configuration, identifier: McpResources::Status.qualified_name, enabled: false) + end + + it_behaves_like "MCP result response" + + it "does not include the status resource template" do + subject + + resource = parsed_results.fetch("resourceTemplates").find { |t| t.fetch("name") == "status" } + expect(resource).to be_nil + 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 diff --git a/spec/requests/mcp/resources_list_spec.rb b/spec/requests/mcp/resources_list_spec.rb new file mode 100644 index 000000000000..757cc1d6f5a8 --- /dev/null +++ b/spec/requests/mcp/resources_list_spec.rb @@ -0,0 +1,133 @@ +# 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 "MCP resources/list", 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") } + let(:request_body) do + { + jsonrpc: "2.0", + id: "Test-Request", + method: "resources/list", + params: {} + } + end + let(:parsed_results) { JSON.parse(last_response.body).fetch("result") } + + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") } + let(:resource_config) { create(:mcp_configuration, identifier: McpResources::StatusList.qualified_name) } + let(:resource_template_config) { create(:mcp_configuration, identifier: McpResources::Status.qualified_name) } + + before do + server_config.save! + resource_config.save! + resource_template_config.save! + end + + context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do + it_behaves_like "MCP result response" + + it "includes the status_list resource" do + subject + + resource = parsed_results.fetch("resources").find { |t| t.fetch("name") == "status_list" } + expect(resource).not_to be_nil + expect(resource.fetch("title")).to eq(resource_config.title) + expect(resource.fetch("description")).to eq(resource_config.description) + end + + it "returns a fully qualified uri" do + subject + + resource = parsed_results.fetch("resources").find { |t| t.fetch("name") == "status_list" } + expect(resource.fetch("uri")).to eq("http://test.host/api/v3/statuses") + end + + it "does not include resource templates" do + subject + + resource = parsed_results.fetch("resources").find { |t| t.fetch("name") == "status" } + expect(resource).to be_nil + end + + context "when not passing a Bearer token" do + subject do + header "X-Authentication-Scheme", "Bearer" + header "Content-Type", "application/json" + post "/mcp", request_body.to_json + end + + it_behaves_like "MCP unauthenticated response" + end + + context "when passing a Bearer token with a wrong scope" do + let(:access_token) { create(:oauth_access_token, scopes: "api_v3") } + + it_behaves_like "MCP unauthenticated response" + end + + context "when the MCP server is disabled via configuration" do + let(:server_config) { create(:mcp_configuration, identifier: "mcp_server", enabled: false) } + + it "responds in a 404" do + subject + expect(last_response).to have_http_status(404) + end + end + + context "when the status_list resource is disabled" do + let(:resource_config) { create(:mcp_configuration, identifier: McpResources::StatusList.qualified_name, enabled: false) } + + it_behaves_like "MCP result response" + + it "does not include the status_list resource" do + subject + + resource = parsed_results.fetch("resources").find { |t| t.fetch("name") == "status_list" } + expect(resource).to be_nil + 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 diff --git a/spec/requests/mcp/tools_list_spec.rb b/spec/requests/mcp/tools_list_spec.rb index fe2b4045494a..f08fd361ffb8 100644 --- a/spec/requests/mcp/tools_list_spec.rb +++ b/spec/requests/mcp/tools_list_spec.rb @@ -93,6 +93,19 @@ expect(last_response).to have_http_status(404) end end + + context "when the search_project tool is disabled" do + let(:tool_config) { create(:mcp_configuration, identifier: McpTools::SearchProject.qualified_name, enabled: false) } + + it_behaves_like "MCP result response" + + it "does not include the search_project tool" do + subject + + tool = parsed_results.fetch("tools").find { |t| t.fetch("name") == "search_project" } + expect(tool).to be_nil + end + end end context "when the mcp_server enterprise feature is disabled" do diff --git a/spec/services/uri_template_spec.rb b/spec/services/uri_template_spec.rb new file mode 100644 index 000000000000..d4a33b9ab6c3 --- /dev/null +++ b/spec/services/uri_template_spec.rb @@ -0,0 +1,106 @@ +# 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 UriTemplate do + let(:uri_template) { described_class.new(template_string) } + let(:template_string) { "https://openproject.local/statuses/{id}" } + let(:uri) { "https://openproject.local/statuses/123" } + + describe "initializer" do + subject { uri_template } + + it "raises no errors" do + subject + end + + context "when passing a nil string" do + let(:template_string) { nil } + + it "raises an ArgumentError" do + expect { subject }.to raise_error(ArgumentError, "template_string can't be nil") + end + end + end + + describe "#match?" do + subject { uri_template.match?(uri) } + + context "when the URL matches" do + it { is_expected.to be_truthy } + end + + context "when the URL is different" do + let(:uri) { "https://openproject.local/types/123" } + + it { is_expected.to be_falsey } + end + + context "when the URL would leave a variable empty" do + let(:uri) { "https://openproject.local/statuses/" } + + it { is_expected.to be_falsey } + end + + context "when the template placeholder contains unsupported characters" do + let(:template_string) { "https://openproject.local/statuses/{the id}" } + + it { is_expected.to be_falsey } + end + end + + describe "#parse" do + subject { uri_template.parse(uri) } + + context "when the URL matches" do + it { is_expected.to eq({ id: "123" }) } + end + + context "when the URL is different" do + let(:uri) { "https://openproject.local/types/123" } + + it { is_expected.to be_nil } + end + + context "when expanded variable contains dashes" do + let(:uri) { "https://openproject.local/statuses/red-alert" } + + it { is_expected.to eq({ id: "red-alert" }) } + end + + context "when templating multiple variables" do + let(:template_string) { "https://openproject.local/statuses/{id}/something/{thing}" } + let(:uri) { "https://openproject.local/statuses/123/something/unicorn" } + + it { is_expected.to eq({ id: "123", thing: "unicorn" }) } + end + end +end