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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
# Runtime base class for MCP tools. Every Tool class nests a `Runner < BaseTool::Runner`
# whose `#run` method is the actual tool implementation.
class McpServer::BaseTool::Runner
NOT_DRAFT_MESSAGE = 'Project is not in draft. Only draft projects can be modified via MCP.'
NOT_DRAFT_MESSAGE = 'Project is not in draft. On this platform, only draft projects can be ' \
'modified via MCP; published projects are only modifiable on demo and ' \
'trial platforms.'

# Lifecycle stages on which the draft-only rule is lifted, so the MCP can also modify
# published (and archived) projects. Never add 'active' here: live client platforms have
# real participants, and modifying live projects must stay a human action there.
PUBLISHED_WRITABLE_LIFECYCLES = %w[demo trial].freeze

include Pundit::Authorization
include McpServer::BaseTool::ResponseHelpers
Expand Down Expand Up @@ -44,9 +51,14 @@ def clear_uploaders!(record, attributes)
# must call this with the target's project before doing the work.
def authorize_project!(project)
return if project.admin_publication.draft?
return if published_writable_platform?

raise Pundit::NotAuthorizedErrorWithReason,
reason: NOT_DRAFT_MESSAGE,
message: NOT_DRAFT_MESSAGE
end

def published_writable_platform?
PUBLISHED_WRITABLE_LIFECYCLES.include?(AppConfiguration.instance.lifecycle_stage)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ def annotations

def description
<<~DESC.squish
Deletes a resource by id. Only works on resources whose target project is in draft.
Deletes a resource by id. Only works on resources whose target project is in draft,
except on demo and trial platforms, where published projects can be targeted too.
Destroying a project or a phase fails if any of its inputs would be deleted along with it.
DESC
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# frozen_string_literal: true

require 'rails_helper'

describe McpServer::BaseTool::Runner do
describe '#authorize_project!' do
let(:runner) { described_class.new(params: {}, server_context: {}, current_user: nil) }

def authorize!(project)
runner.send(:authorize_project!, project)
end

it 'allows draft projects' do
expect { authorize!(create(:project, :draft)) }.not_to raise_error
end

it 'refuses published projects on every lifecycle stage other than demo and trial' do
project = create(:project)

%w[active expired_trial churned not_applicable].each do |stage|
change_lifecycle_stage(stage)
expect { authorize!(project) }
.to raise_error(Pundit::NotAuthorizedErrorWithReason), "expected refusal on #{stage}"
end
end

%w[demo trial].each do |stage|
context "on a #{stage} platform" do
before { change_lifecycle_stage(stage) }

it 'allows published projects' do
expect { authorize!(create(:project)) }.not_to raise_error
end

it 'allows archived projects' do
project = create(:project, admin_publication_attributes: { publication_status: 'archived' })
expect { authorize!(project) }.not_to raise_error
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ def destroy(resource_type, id)
expect { phase.reload }.not_to raise_error
end

# Deliberate: lifting the draft-only rule on demo platforms extends to destruction.
# The inputs-present guards below still apply there.
it 'destroys a phase of a published project on a demo platform' do
change_lifecycle_stage('demo')
published_project = create(:project, admin_publication_attributes: { publication_status: 'published' })
phase = create(:phase, project: published_project)

response = destroy('phase', phase.id)

expect(response).not_to be_error
expect { phase.reload }.to raise_error(ActiveRecord::RecordNotFound)
end

it 'refuses to destroy a project that has inputs' do
create(:idea_status_proposed)
phase = create(:native_survey_phase, project: draft_project)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ def run(params)
expect(response).to be_unauthorized_project
end

it 'updates a published project on a demo platform' do
change_lifecycle_stage('demo')
published = create(:project)

response = nil
expect { response = run(project_id: published.id, title_multiloc: { 'en' => 'New' }) }
.to change { published.reload.title_multiloc }

expect(response).not_to be_error
end

it 'returns a not-found error when the project is missing' do
response = run(project_id: SecureRandom.uuid, title_multiloc: { 'en' => 'New' })

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module McpServer
module LifecycleStageSpecSupport
# Sets the tenant's lifecycle stage for the current example. Writes with
# update_column because AppConfiguration refuses normal saves that change the
# stage from or to 'demo'.
def change_lifecycle_stage(stage)
config = AppConfiguration.instance
config.settings['core']['lifecycle_stage'] = stage
config.update_column(:settings, config.settings)
end
end
end

RSpec.configure do |config|
config.include McpServer::LifecycleStageSpecSupport
end