mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user