From 95cbaabb7eb9d4155e8ab107fe7db35a40c19b27 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Thu, 18 Dec 2025 17:06:18 +0100 Subject: [PATCH] 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. --- app/models/mcp_configuration.rb | 32 ++++++ app/seeders/mcp_configuration_seeder.rb | 76 +++++++++++++ app/seeders/root_seeder.rb | 7 ++ app/services/mcp_tools.rb | 8 ++ app/services/mcp_tools/base.rb | 106 ++++++++++++++++++ app/services/mcp_tools/search_project.rb | 52 ++++----- ...0251218133700_create_mcp_configurations.rb | 41 +++++++ lib/api/mcp.rb | 14 ++- spec/factories/mcp_configuration_factory.rb | 38 +++++++ .../requests/mcp/tools/search_project_spec.rb | 14 +++ spec/requests/mcp/tools_list_spec.rb | 19 ++++ 11 files changed, 374 insertions(+), 33 deletions(-) create mode 100644 app/models/mcp_configuration.rb create mode 100644 app/seeders/mcp_configuration_seeder.rb create mode 100644 app/services/mcp_tools/base.rb create mode 100644 db/migrate/20251218133700_create_mcp_configurations.rb create mode 100644 spec/factories/mcp_configuration_factory.rb diff --git a/app/models/mcp_configuration.rb b/app/models/mcp_configuration.rb new file mode 100644 index 00000000000..102bb9c109f --- /dev/null +++ b/app/models/mcp_configuration.rb @@ -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 diff --git a/app/seeders/mcp_configuration_seeder.rb b/app/seeders/mcp_configuration_seeder.rb new file mode 100644 index 00000000000..95f9458bc68 --- /dev/null +++ b/app/seeders/mcp_configuration_seeder.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. +#++ +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 diff --git a/app/seeders/root_seeder.rb b/app/seeders/root_seeder.rb index f3c97029caa..021b78b0202 100644 --- a/app/seeders/root_seeder.rb +++ b/app/seeders/root_seeder.rb @@ -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) diff --git a/app/services/mcp_tools.rb b/app/services/mcp_tools.rb index 650a373203a..f0887cb059b 100644 --- a/app/services/mcp_tools.rb +++ b/app/services/mcp_tools.rb @@ -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 diff --git a/app/services/mcp_tools/base.rb b/app/services/mcp_tools/base.rb new file mode 100644 index 00000000000..ad60610ded2 --- /dev/null +++ b/app/services/mcp_tools/base.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. +#++ + +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 diff --git a/app/services/mcp_tools/search_project.rb b/app/services/mcp_tools/search_project.rb index cb986432078..a3d6cf71388 100644 --- a/app/services/mcp_tools/search_project.rb +++ b/app/services/mcp_tools/search_project.rb @@ -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 diff --git a/db/migrate/20251218133700_create_mcp_configurations.rb b/db/migrate/20251218133700_create_mcp_configurations.rb new file mode 100644 index 00000000000..dec733e2b1a --- /dev/null +++ b/db/migrate/20251218133700_create_mcp_configurations.rb @@ -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 diff --git a/lib/api/mcp.rb b/lib/api/mcp.rb index 5e92c1dbdbe..d508419296d 100644 --- a/lib/api/mcp.rb +++ b/lib/api/mcp.rb @@ -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 } ) diff --git a/spec/factories/mcp_configuration_factory.rb b/spec/factories/mcp_configuration_factory.rb new file mode 100644 index 00000000000..a7f613958db --- /dev/null +++ b/spec/factories/mcp_configuration_factory.rb @@ -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 diff --git a/spec/requests/mcp/tools/search_project_spec.rb b/spec/requests/mcp/tools/search_project_spec.rb index 49bbb425dba..2d73740bc61 100644 --- a/spec/requests/mcp/tools/search_project_spec.rb +++ b/spec/requests/mcp/tools/search_project_spec.rb @@ -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 diff --git a/spec/requests/mcp/tools_list_spec.rb b/spec/requests/mcp/tools_list_spec.rb index 2ff3f63bcec..fe2b4045494 100644 --- a/spec/requests/mcp/tools_list_spec.rb +++ b/spec/requests/mcp/tools_list_spec.rb @@ -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