Add MCP resource handling

Allows to list resources and resource templates and reading
their contents.
This commit is contained in:
Jan Sandbrink
2026-01-13 16:00:54 +01:00
parent f48e390bda
commit ac4e9626ee
28 changed files with 1826 additions and 7 deletions
+8 -4
View File
@@ -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 @@ class McpConfigurationSeeder < Seeder
)
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 @@ class McpConfigurationSeeder < Seeder
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
+76
View File
@@ -0,0 +1,76 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module 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
+114
View File
@@ -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
+46
View File
@@ -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
+46
View File
@@ -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
+43
View File
@@ -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
+46
View File
@@ -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
+43
View File
@@ -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
+46
View File
@@ -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
+46
View File
@@ -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
@@ -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
+1 -1
View File
@@ -41,7 +41,7 @@ module McpTools
end
def tools_by_name
all.index_by(&:qualified_name)
@tools_by_name ||= all.index_by(&:qualified_name)
end
end
end
+58
View File
@@ -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
@@ -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:
+4
View File
@@ -57,9 +57,13 @@ module API
# 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
+46 -1
View File
@@ -79,7 +79,52 @@ RSpec.shared_examples_for "MCP response with structured content" do
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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
@@ -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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
@@ -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 # rubocop:disable RSpec/SpecFilePathFormat
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
+133
View File
@@ -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
+13
View File
@@ -93,6 +93,19 @@ RSpec.describe "MCP tools/list", with_flag: { mcp_server: true } do
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
+106
View File
@@ -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