Skip to content

Commit 7f936cc

Browse files
Merge pull request #21646 from opf/mcp_tool_refactor
Refactor how filtering in MCP tools works
2 parents 8472b78 + c5783c4 commit 7f936cc

File tree

3 files changed

+67
-17
lines changed

3 files changed

+67
-17
lines changed

app/services/mcp_tools/base.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,43 @@ def output_schema(schema = nil)
6565
@output_schema
6666
end
6767

68+
##
69+
# Defines a filter for selecting results through input parameters. Only one of filter_proc and filter_class are allowed at
70+
# the same time. If none is provided, a default where-based filter is created, using name as the filtered attribute name.
71+
#
72+
# Filters defined here can later be applied by the tool implementation using #apply_filters.
73+
#
74+
# @param name [Symbol] The name of the input parameter used for filtering.
75+
# @param filter_class [Queries::Filters::Base] A shared filter implementation to be used to perform filtering.
76+
# @param operator [String] When using a filter_class, this is the operator that will be used for filtering. Default: "="
77+
# @param filter_proc [Proc] A callback procedure used for filtering that must accept two arguments:
78+
# The base scope that the filter applies to and the value that's used as a filter input.
79+
# @example
80+
# filter :id
81+
#
82+
# @example
83+
# filter :name, filter_class: Queries::Projects::Filters::NameFilter, operator: "~"
84+
#
85+
# @example
86+
# filter :status, filter_proc: ->(scope, value) { scope.where(status_name: value) }
87+
def filter(name, filter_class: nil, filter_proc: nil, operator: "=")
88+
if filter_class && filter_proc
89+
raise ArgumentError, "filter_proc and filter_class are mutually exclusive, please only specify one"
90+
end
91+
92+
if filter_class
93+
filter_proc = ->(scope, value) { filter_class.create!(operator:, values: Array(value)).apply_to(scope) }
94+
elsif !filter_proc
95+
filter_proc = ->(scope, value) { scope.where(name.to_sym => value) }
96+
end
97+
98+
filters[name.to_sym] = filter_proc
99+
end
100+
101+
def filters
102+
@filters ||= {}
103+
end
104+
68105
def tool
69106
config = McpConfiguration.find_by(identifier: qualified_name)
70107
return nil if config.nil?
@@ -98,9 +135,26 @@ def handle_request(**)
98135
MCP::Tool::Response.new([{ type: "text", text: result.to_json }], structured_content: result)
99136
end
100137

138+
private
139+
101140
# Intended to be implemented by subclasses. It should return a structured result (e.g. a Hash or Array).
102141
def call(**)
103142
raise NotImplemented, "#{self.class} needs to implement #call method"
104143
end
144+
145+
# Usable by tool implementations. Takes a scope and filters it according to the passed params.
146+
# Filtering happens based on the filters defined for the tool, see .filter.
147+
def apply_filters(scope, params)
148+
params.each do |name, value|
149+
filter_proc = filter_proc_for(name)
150+
scope = filter_proc.call(scope, value)
151+
end
152+
153+
scope
154+
end
155+
156+
def filter_proc_for(name)
157+
self.class.filters[name] || raise(ArgumentError, "Don't know how to handle filter argument called #{name}")
158+
end
105159
end
106160
end

app/services/mcp_tools/search_project.rb

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class SearchProject < Base
3939

4040
name "search_project"
4141

42+
filter :name, filter_class: Queries::Projects::Filters::NameFilter, operator: "~"
43+
filter :identifier
44+
filter :status_code
45+
4246
input_schema(
4347
properties: {
4448
name: { type: "string", description: "Name of the project. Accepts partial project names, not case-sensitive." },
@@ -52,23 +56,9 @@ class SearchProject < Base
5256
items: JsonSchemaLoader.new.load("project_model")
5357
)
5458

55-
def call(name: nil, identifier: nil, status_code: nil)
56-
query = { name:, identifier:, status_code: }.compact
57-
if query.present?
58-
projects = projects_for_query(query)
59-
projects.map { |p| API::V3::Projects::ProjectRepresenter.create(p, current_user: User.current) }
60-
else
61-
[]
62-
end
63-
end
64-
65-
private
66-
67-
def projects_for_query(query)
68-
name = query.delete(:name)
69-
projects = Project.visible.where(query).limit(MAX_SIZE)
70-
projects = projects.where("name ILIKE '%#{OpenProject::SqlSanitization.quoted_sanitized_sql_like(name)}%'") if name
71-
projects
59+
def call(**query)
60+
projects = apply_filters(Project.visible.limit(MAX_SIZE), query)
61+
projects.map { |p| API::V3::Projects::ProjectRepresenter.create(p, current_user: User.current) }
7262
end
7363
end
7464
end

spec/models/queries/projects/filters/project_status_filter_spec.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,10 @@
5757
end
5858
end
5959
end
60+
61+
it_behaves_like "list_optional query filter" do
62+
let(:attribute) { :status_code }
63+
let(:model) { Project }
64+
let(:valid_values) { ["0", "1", "2"] }
65+
end
6066
end

0 commit comments

Comments
 (0)