Add tools that return the content of certain resources

They are useful for clients that don't support resources,
or ignore them, even if they could support them.
This commit is contained in:
Jan Sandbrink
2026-02-03 15:35:05 +01:00
parent abe1cffd75
commit 0e7a19b54c
8 changed files with 356 additions and 9 deletions
+11 -7
View File
@@ -60,17 +60,21 @@ module McpResources
end
def read_resource(uri)
resource_class = enabled.find { |r| r.uri == uri || r.uri_template&.match?(uri) }
content = resource_class&.read(uri)
content = read_resource_content(uri)
return [] if content.nil?
[
{
uri: uri,
mimeType: "application/json",
text: content.to_json
}
format_json_resource(uri, content)
]
end
def read_resource_content(uri, resources_considered: enabled)
resource_class = resources_considered.find { |r| r.uri == uri || r.uri_template&.match?(uri) }
resource_class&.read(uri)
end
def format_json_resource(uri, content)
{ uri:, mimeType: "application/json", text: content.to_json }
end
end
end
+2
View File
@@ -32,6 +32,8 @@ module McpTools
class << self
def all
[
McpTools::ListStatuses,
McpTools::ListTypes,
McpTools::SearchProjects,
McpTools::SearchWorkPackages
]
+6 -2
View File
@@ -113,7 +113,7 @@ module McpTools
def read_annotations
# Initialize default annotations, if none are present
annotations(read_only: false) if @annotations.nil?
annotations(read_only: false, destructive: true, idempotent: false) if @annotations.nil?
@annotations
end
@@ -151,7 +151,7 @@ module McpTools
validate_root_output_schema!(@tool_context.output_schema)
end
MCP::Tool::Response.new([{ type: "text", text: result.to_json }], structured_content: result)
format_result(result)
end
private
@@ -161,6 +161,10 @@ module McpTools
raise NotImplemented, "#{self.class} needs to implement #call method"
end
def format_result(result)
MCP::Tool::Response.new([{ type: "text", text: result.to_json }], structured_content: result)
end
def current_user
@server_context[:current_user]
end
+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 McpTools
class ListStatuses < ResourceProxyTool
default_title "List statuses"
default_description "Lists all work package statuses available on this OpenProject instance. " \
"Also available as an MCP resource."
name "list_statuses"
resource McpResources::StatusList
resource_schema "status_collection_model"
resource_annotations
end
end
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module McpTools
class ListTypes < ResourceProxyTool
default_title "List types"
default_description "Lists all work package types available on this OpenProject instance. Also available as an MCP resource."
name "list_types"
resource McpResources::TypeList
resource_schema "types_model"
resource_annotations
end
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module McpTools
# A tool that effectively only serves a result that could've been obtained by fetching a resource
# Useful for clients that don't support resources, or ignore them, even if they could support them.
class ResourceProxyTool < Base
class << self
def resource(resource = nil)
@resource = resource if resource.present?
@resource
end
def resource_schema(schema_definition)
output_schema(JsonSchemaLoader.new.load(schema_definition))
end
def resource_annotations
annotations read_only: true, idempotent: true, destructive: false
end
end
private
def call
McpResources.read_resource_content(self.class.resource.uri, resources_considered: McpResources.all)
end
def format_result(content)
MCP::Tool::Response.new(
[{ type: "resource", resource: McpResources.format_json_resource(self.class.resource.uri, content) }],
structured_content: content
)
end
end
end
@@ -0,0 +1,94 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
RSpec.describe McpTools::ListStatuses, with_flag: { mcp_server: true } do
subject do
header "Authorization", "Bearer #{access_token.plaintext_token}"
header "X-Authentication-Scheme", "Bearer"
header "Content-Type", "application/json"
post "/mcp", request_body.to_json
end
let(:access_token) { create(:oauth_access_token, scopes: "mcp", resource_owner: user) }
let(:user) { create(:user) }
let(:request_body) do
{
jsonrpc: "2.0",
id: "Test-Request",
method: "tools/call",
params: {
name: "list_statuses",
arguments: call_args
}
}
end
let(:call_args) { {} }
let(:parsed_results) { JSON.parse(last_response.body).fetch("result") }
let!(:status_a) { create(:status) }
let!(:status_b) { create(:status) }
let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") }
let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name) }
before do
server_config.save!
tool_config.save!
end
context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do
it_behaves_like "MCP response with structured content"
it "finds all statuses" do
subject
expect(parsed_results.dig("structuredContent", "count")).to eq(2)
end
it "responds with properly formatted statuses" do
subject
expect(parsed_results.fetch("structuredContent").to_json).to match_json_schema.from_docs("status_collection_model")
end
context "when the tool is disabled via configuration" do
let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) }
it_behaves_like "MCP error response"
end
end
context "when the mcp_server enterprise feature is disabled" do
it "responds in a 404" do
subject
expect(last_response).to have_http_status(404)
end
end
end
@@ -0,0 +1,94 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
RSpec.describe McpTools::ListTypes, with_flag: { mcp_server: true } do
subject do
header "Authorization", "Bearer #{access_token.plaintext_token}"
header "X-Authentication-Scheme", "Bearer"
header "Content-Type", "application/json"
post "/mcp", request_body.to_json
end
let(:access_token) { create(:oauth_access_token, scopes: "mcp", resource_owner: user) }
let(:user) { create(:user) }
let(:request_body) do
{
jsonrpc: "2.0",
id: "Test-Request",
method: "tools/call",
params: {
name: "list_types",
arguments: call_args
}
}
end
let(:call_args) { {} }
let(:parsed_results) { JSON.parse(last_response.body).fetch("result") }
let!(:type_a) { create(:type) }
let!(:type_b) { create(:type) }
let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") }
let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name) }
before do
server_config.save!
tool_config.save!
end
context "when the mcp_server enterprise feature is enabled", with_ee: %i[mcp_server] do
it_behaves_like "MCP response with structured content"
it "finds all types" do
subject
expect(parsed_results.dig("structuredContent", "count")).to eq(2)
end
it "responds with properly formatted types" do
subject
expect(parsed_results.fetch("structuredContent").to_json).to match_json_schema.from_docs("types_model")
end
context "when the tool is disabled via configuration" do
let(:tool_config) { create(:mcp_configuration, identifier: described_class.qualified_name, enabled: false) }
it_behaves_like "MCP error response"
end
end
context "when the mcp_server enterprise feature is disabled" do
it "responds in a 404" do
subject
expect(last_response).to have_http_status(404)
end
end
end