Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/services/mcp_tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def all
McpTools::ListStatuses,
McpTools::ListTypes,
McpTools::SearchProjects,
McpTools::SearchVersions,
McpTools::SearchWorkPackages
]
end
Expand Down
83 changes: 83 additions & 0 deletions app/services/mcp_tools/search_versions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 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 SearchVersions < Base
default_title "Search versions"
default_description "Search versions matching all of the passed input parameters. " \
"Parameters not passed are ignored. Results are limited to a maximum " \
"of #{page_size} versions. To get the rest of the results, call the tool again with a" \
"page number of 2 or higher."

name "search_versions"
annotations read_only: true, idempotent: true, destructive: false
enable_pagination

filter :name, filter_class: Queries::Versions::Filters::NameFilter, operator: "~"
filter :sharing

input_schema(
type: :object,
properties: {
name: { type: "string", description: "Name of the version. Accepts partial version names, not case-sensitive." },
sharing: {
type: "string",
enum: Version::VERSION_SHARINGS,
description: `The indicator of how the version is shared between projects. This could be:
- 'none': if the version is only available in the defining project
- 'descendants': if the version is shared with the descendants of the defining project
- 'hierarchy': if the version is shared with the descendants or the ancestors of the defining project
- 'tree': if the version is shared with the root project of the defining project and all descendants of the root project
- 'system': if the version is shared globally`
}
}
)

output_schema(
type: :object,
required: ["items"],
properties: {
items: {
type: :array,
items: JsonSchemaLoader.new.load("version_model")
}
}
)

def call(page: nil, **filters)
filtered = apply_filters(Version.visible, filters)
versions = apply_pagination(filtered, page)

{
items: versions.map { |v| API::V3::Versions::VersionRepresenter.create(v, current_user:) }
}
end
end
end
82 changes: 33 additions & 49 deletions docs/api/apiv3/components/schemas/version_model.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
# Schema: VersionModel
---
type: object
required:
- name
- status
- sharing
- createdAt
- updatedAt
properties:
id:
type: integer
description: Version id
readOnly: true
exclusiveMinimum: 0
minimum: 1
name:
type: string
description: Version name
description:
allOf:
- "$ref": "./formattable.yml"
- {}
$ref: "./formattable.yml"
startDate:
type:
- "string"
Expand All @@ -40,62 +31,55 @@ properties:
type: string
format: date-time
description: Time of creation
readOnly: true
updatedAt:
type: string
format: date-time
description: Time of the most recent change to the version
readOnly: true
_links:
type: object
required:
- self
- availableInProjects
- self
- availableInProjects
properties:
update:
allOf:
- "$ref": "./link.yml"
- description: |-
Form endpoint that aids in preparing and performing edits on the version

# Conditions

**Permission**: manage versions
readOnly: true
- $ref: "./link.yml"
- description: |-
Form endpoint that aids in preparing and performing edits on the version

# Conditions

**Permission**: manage versions
updateImmediately:
allOf:
- "$ref": "./link.yml"
- description: |-
Directly perform edits on the version

# Conditions

**Permission**: manage versions
readOnly: true
- $ref: "./link.yml"
- description: |-
Directly perform edits on the version

# Conditions

**Permission**: manage versions
self:
allOf:
- "$ref": "./link.yml"
- description: |-
This version

**Resource**: Version
readOnly: true
- $ref: "./link.yml"
- description: |-
This version

**Resource**: Version
definingProject:
allOf:
- "$ref": "./link.yml"
- description: |-
The workspace to which the version belongs

**Resource**: Workspace
readOnly: true
- $ref: "./link.yml"
- description: |-
The workspace to which the version belongs

**Resource**: Workspace
availableInProjects:
allOf:
- "$ref": "./link.yml"
- description: |-
Workspaces where this version can be used

**Resource**: Workspace
readOnly: true
- $ref: "./link.yml"
- description: |-
Workspaces where this version can be used

**Resource**: Workspace
example:
_links:
self:
Expand Down
2 changes: 1 addition & 1 deletion docs/api/apiv3/paths/versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ get:

+ sharing: filters versions by how they are shared within the server (*none*, *descendants*, *hierarchy*, *tree*, *system*).
+ name: filters versions by their name.
example: '[{ "sharing": { "operator": "*", "values": ["system"] }" }]'
example: '[{ "sharing": { "operator": "=", "values": ["system"] } }]'
in: query
name: filters
required: false
Expand Down
166 changes: 166 additions & 0 deletions spec/requests/mcp/mcp_tools/search_versions_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# 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::SearchVersions, 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(:admin) } # using an admin, so that projects are visible
let(:request_body) do
{
jsonrpc: "2.0",
id: "Test-Request",
method: "tools/call",
params: {
name: "search_versions",
arguments: call_args
}
}
end
let(:call_args) { {} }
let(:parsed_results) { JSON.parse(last_response.body).fetch("result") }

let(:project) { create(:project) }

let!(:version_not_shared) { create(:version, project:, sharing: :none, name: "v1.0.1-alpha") }
let!(:version_shared_globally) { create(:version, project:, sharing: :system, name: "v1.1.0") }

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 versions without filters" do
subject
expect(parsed_results.dig("structuredContent", "items").size).to eq(2)
end

it "responds with properly formatted versions" do
subject
parsed_results.dig("structuredContent", "items").each do |version|
expect(version.to_json).to match_json_schema.from_docs("version_model")
end
end

context "when passing an exact name" do
let(:call_args) { { name: "v1.0.1-alpha" } }

it "finds the version" do
subject
expect(parsed_results.dig("structuredContent", "items").size).to eq(1)
end
end

context "when passing a non-exact name" do
let(:call_args) { { name: "alpha" } }

it "finds the version" do
subject
expect(parsed_results.dig("structuredContent", "items").size).to eq(1)
end
end

context "when passing a version sharing strategy" do
let(:call_args) { { sharing: "system" } }

it "finds the version" do
subject
expect(parsed_results.dig("structuredContent", "items").size).to eq(1)
end

context "and when passing a version name" do
let(:call_args) { { sharing: "system", name: "v1" } }

it "finds the version" do
subject
expect(parsed_results.dig("structuredContent", "items").size).to eq(1)
end
end

context "and when passing a version name of a version with a different sharing strategy" do
let(:call_args) { { sharing: "system", name: "alpha" } }

it "does not find the version" do
subject
expect(parsed_results.dig("structuredContent", "items")).to be_empty
end
end
end

describe "pagination" do
let(:page_size) { 10 }
let(:overspilling_versions) { 5 }
let(:version_count) { page_size + overspilling_versions }
let(:call_args) { { name: "beta" } }

before do
allow(described_class).to receive(:page_size).and_return(page_size)

version_count.times do |idx|
create(:version, sharing: :none, name: "v1.2.#{idx}-beta")
end
end

it "returns only results up to the page size" do
subject
expect(parsed_results.dig("structuredContent", "items").count).to eq(page_size)
end

context "if another page is requested" do
let(:call_args) { { name: "beta", page: 2 } }

it "returns the requested page" do
subject
expect(parsed_results.dig("structuredContent", "items").count).to eq(overspilling_versions)
end
end
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
Loading