Allow to configure MCP server, tools and resources

Instead of defining the mcp tools as static classes, we are now
using the Mcp::Tool.define flow to build them. This allows us to
change their description on every call and thus define it through
the configuration stored in the database.
This commit is contained in:
Jan Sandbrink
2025-12-18 17:06:18 +01:00
parent 78f8607de6
commit 95cbaabb7e
11 changed files with 374 additions and 33 deletions
+32
View File
@@ -0,0 +1,32 @@
# 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.
#++
class McpConfiguration < ApplicationRecord
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.
#++
class McpConfigurationSeeder < Seeder
def seed_data!
seed_server_config if server_missing?
seed_tool_configs
end
def applicable?
server_missing? || tools_missing?
end
def not_applicable_message
"No seeding of additional MCP configuration necessary."
end
private
def seed_server_config
McpConfiguration.create!(
identifier: API::Mcp::CONFIGURATION_IDENTIFIER,
title: Setting.app_title,
description: "Performs project management tasks on the given installation of OpenProject.",
enabled: true
)
end
def seed_tool_configs
McpTools.all.each do |thing| # rubocop:disable Rails/FindEach
next if McpConfiguration.find_by(identifier: thing.qualified_name)
McpConfiguration.create!(
identifier: thing.qualified_name,
title: thing.default_title,
description: thing.default_description,
enabled: true
)
end
end
def server_missing?
McpConfiguration.where(identifier: API::Mcp::CONFIGURATION_IDENTIFIER).empty?
end
def tools_missing?
(McpTools.all.map(&:qualified_name) - McpConfiguration.pluck(:identifier)).any?
end
end
+7
View File
@@ -81,6 +81,7 @@ class RootSeeder < Seeder
seed_development_data if seed_development_data?
seed_plugins_data
seed_env_data
seed_mcp_configuration
cleanup_seed_data
end
@@ -179,6 +180,12 @@ class RootSeeder < Seeder
end
end
def seed_mcp_configuration
print_status "*** Seeding MCP configuration"
McpConfigurationSeeder.new(seed_data).seed!
McpConfigurationSeeder
end
def desired_lang
desired_lang = ENV.fetch("OPENPROJECT_SEED_LOCALE", Setting.default_language)
+8
View File
@@ -35,5 +35,13 @@ module McpTools
McpTools::SearchProject
]
end
def enabled
McpConfiguration.where(enabled: true).pluck(:identifier).filter_map { |name| tools_by_name[name] }
end
def tools_by_name
all.index_by(&:qualified_name)
end
end
end
+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.
#++
module McpTools
class Base
class << self
def qualified_name
"tools/#{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 input_schema(schema = nil)
@input_schema = schema if schema.present?
@input_schema
end
def output_schema(schema = nil)
@output_schema = schema if schema.present?
@output_schema
end
def tool
config = McpConfiguration.find_by(identifier: qualified_name)
return nil if config.nil?
implementation = self
MCP::Tool.define(
name:,
title: config.title,
description: config.description,
input_schema:,
output_schema:
) do |opts|
implementation.new(tool_context: self).handle_request(**opts)
end
end
end
def initialize(tool_context:)
@tool_context = tool_context
end
def handle_request(**)
result = call(**)
if Rails.env.local?
# We are only validating the output during development, so we can see errors during dev, but do not break the
# API in production due to minor schema differences.
@tool_context.output_schema.validate_result(result.to_json)
end
MCP::Tool::Response.new([{ type: "text", text: result.to_json }], structured_content: result)
end
# Intended to be implemented by subclasses. It should return a structured result (e.g. a Hash or Array).
def call(**)
raise NotImplemented, "#{self.class} needs to implement #call method"
end
end
end
+21 -31
View File
@@ -29,12 +29,15 @@
#++
module McpTools
class SearchProject < MCP::Tool
class SearchProject < Base
# TODO: The mcp gem does not support pagination, so we only limit the number of results for now
MAX_SIZE = 100
tool_name "search_project"
description "Search projects matching all of the passed input parameters. Parameters not passed are ignored. " \
"Results are limited to a maximum of #{MAX_SIZE} projects."
default_title "Search projects"
default_description "Search projects matching all of the passed input parameters. " \
"Parameters not passed are ignored. Results are limited to a maximum of #{MAX_SIZE} projects."
name "search_project"
input_schema(
properties: {
@@ -49,36 +52,23 @@ module McpTools
items: JsonSchemaLoader.new.load("project_model")
)
class << self
def call(name: nil, identifier: nil, status_code: nil)
query = { name:, identifier:, status_code: }.compact
result = if query.present?
projects = projects_for_query(query)
projects.map { |p| API::V3::Projects::ProjectRepresenter.create(p, current_user: User.current) }
else
[]
end
if Rails.env.development?
# We are only validating the output during development, so we can see errors during dev, but do not break the
# API in production due to minor schema differences.
output_schema.validate_result(result.to_json)
end
MCP::Tool::Response.new(
[{ type: "text", text: result.to_json }],
structured_content: result
)
def call(name: nil, identifier: nil, status_code: nil)
query = { name:, identifier:, status_code: }.compact
if query.present?
projects = projects_for_query(query)
projects.map { |p| API::V3::Projects::ProjectRepresenter.create(p, current_user: User.current) }
else
[]
end
end
private
private
def projects_for_query(query)
name = query.delete(:name)
projects = Project.visible.where(query).limit(MAX_SIZE)
projects = projects.where("name ILIKE '%#{OpenProject::SqlSanitization.quoted_sanitized_sql_like(name)}%'") if name
projects
end
def projects_for_query(query)
name = query.delete(:name)
projects = Project.visible.where(query).limit(MAX_SIZE)
projects = projects.where("name ILIKE '%#{OpenProject::SqlSanitization.quoted_sanitized_sql_like(name)}%'") if name
projects
end
end
end
@@ -0,0 +1,41 @@
# 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.
#++
class CreateMcpConfigurations < ActiveRecord::Migration[8.0]
def change
create_table :mcp_configurations do |t|
t.string :identifier, null: false
t.string :title, null: false
t.string :description, null: false
t.boolean :enabled, null: false, default: false
t.timestamps null: false
end
end
end
+12 -2
View File
@@ -30,6 +30,8 @@
module API
class Mcp < ::API::RootAPI
CONFIGURATION_IDENTIFIER = "mcp_server"
include ::API::AppsignalAPI
default_format :json
@@ -37,16 +39,24 @@ module API
error_representer ::API::Mcp::ErrorRepresenter, :json
authentication_scope OpenProject::Authentication::Scope::MCP_SCOPE
helpers do
def server_config
@server_config ||= McpConfiguration.find_or_initialize_by(identifier: CONFIGURATION_IDENTIFIER)
end
end
post "/" do
if !OpenProject::FeatureDecisions.mcp_server_active? || !EnterpriseToken.allows_to?(:mcp_server)
if !OpenProject::FeatureDecisions.mcp_server_active? || !EnterpriseToken.allows_to?(:mcp_server) || !server_config.enabled?
status 404
return "MCP server is not available."
end
server = MCP::Server.new(
name: "openproject_mcp",
title: server_config.title,
# description: server_config.description, # not yet supported by mcp gem
version: "1.0.0",
tools: McpTools.all,
tools: McpTools.enabled.map(&:tool),
server_context: { user_id: User.current.id }
)
@@ -0,0 +1,38 @@
# 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.
#++
FactoryBot.define do
factory :mcp_configuration do
identifier { "tools/tool_name" }
title { "The fancy tool" }
description { "This tool is very fancy and can be utilized for fancy tooling." }
enabled { true }
end
end
@@ -57,6 +57,14 @@ RSpec.describe "MCP search_project tool", with_flag: { mcp_server: true } do
let!(:project_a) { create(:project, identifier: "abc", name: "The ABC Project", status_code: :on_track) }
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) }
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"
@@ -139,6 +147,12 @@ RSpec.describe "MCP search_project tool", with_flag: { mcp_server: true } do
expect(parsed_results.fetch("structuredContent")).to be_empty
end
end
context "when the tool is disabled via configuration" do
let(:tool_config) { create(:mcp_configuration, identifier: McpTools::SearchProject.qualified_name, enabled: false) }
it_behaves_like "MCP error response"
end
end
context "when the mcp_server enterprise feature is disabled" do
+19
View File
@@ -49,6 +49,14 @@ RSpec.describe "MCP tools/list", with_flag: { mcp_server: true } do
end
let(:parsed_results) { JSON.parse(last_response.body).fetch("result") }
let(:server_config) { create(:mcp_configuration, identifier: "mcp_server") }
let(:tool_config) { create(:mcp_configuration, identifier: McpTools::SearchProject.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 result response"
@@ -57,6 +65,8 @@ RSpec.describe "MCP tools/list", with_flag: { mcp_server: true } do
tool = parsed_results.fetch("tools").find { |t| t.fetch("name") == "search_project" }
expect(tool).not_to be_nil
expect(tool.fetch("title")).to eq(tool_config.title)
expect(tool.fetch("description")).to eq(tool_config.description)
end
context "when not passing a Bearer token" do
@@ -74,6 +84,15 @@ RSpec.describe "MCP tools/list", with_flag: { mcp_server: true } do
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
end
context "when the mcp_server enterprise feature is disabled" do