From 45709c9371d356f0da1ac8c76862a27b7a4f9213 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:02:20 +0200 Subject: [PATCH 01/32] feat(mcp): add modelcontextprotocol ruby-sdk dependency --- Gemfile | 2 ++ Gemfile.lock | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/Gemfile b/Gemfile index 5a3e118a..100e4914 100644 --- a/Gemfile +++ b/Gemfile @@ -66,6 +66,8 @@ gem 'mina' gem 'lograge' # for listening to file modifications gem 'listen' +# as MCP server SDK (Model Context Protocol) +gem 'mcp', '~> 1.7' # FIXME: activesupport-7.0.8.7 expects those to be in the standard library, which they aren't anymore since 3.4.0 gem 'drb' gem 'mutex_m' diff --git a/Gemfile.lock b/Gemfile.lock index e87c303f..13c72d44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -157,6 +157,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) + hana (1.3.7) hirb (0.7.3) i18n (1.15.2) concurrent-ruby (~> 1.0) @@ -179,6 +180,11 @@ GEM js_cookie_rails (2.2.0) railties (>= 3.1) json (2.21.2) + json_schemer (2.5.0) + bigdecimal + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -215,6 +221,8 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) + mcp (1.7.0) + json_schemer (>= 2.4) method_source (1.1.0) middleware (0.1.0) mina (1.2.5) @@ -410,6 +418,7 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + simpleidn (0.3.0) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -487,6 +496,7 @@ DEPENDENCIES kaminari listen lograge + mcp (~> 1.7) mina mousetrap-rails mutex_m From f5bd652322222b049caa7734040cff16c10944bc Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:21:45 +0200 Subject: [PATCH 02/32] feat(mcp): add endpoint skeleton with ServerInfoTool --- app/controllers/mcp_controller.rb | 44 +++++++++++++++++++ app/mcp/mcp_auth.rb | 9 ++++ app/mcp/server_info_tool.rb | 25 +++++++++++ config/routes.rb | 2 + spec/requests/mcp_spec.rb | 73 +++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+) create mode 100644 app/controllers/mcp_controller.rb create mode 100644 app/mcp/mcp_auth.rb create mode 100644 app/mcp/server_info_tool.rb create mode 100644 spec/requests/mcp_spec.rb diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 00000000..c5449a7c --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,44 @@ +require_relative '../mcp/server_info_tool' +require_relative '../mcp/mcp_auth' + +# JSON-RPC endpoint for the Model Context Protocol (Streamable HTTP, stateless). +class McpController < ApplicationController + skip_before_action :store_location, :load_news, :set_episode, :load_notifications + skip_before_action :authenticate_user! + skip_forgery_protection only: [:create] + + def create + return head :unsupported_media_type unless request.media_type == 'application/json' + + server = MCP::Server.new( + name: 'hackweek', + version: '1.0.0', + tools: [ServerInfoTool], + server_context: { user: mcp_user } + ) + transport_options = { stateless: true, serve_subscriptions_listen: false } + # DNS-rebinding protection only allows loopback Hosts by default, which + # rejects the test suite's dummy host; widen it in test env only. + transport_options[:allowed_hosts] = [request.host] if Rails.env.test? + transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, **transport_options) + status, headers, body = transport.handle_request(request) + + render(json: body.first, status:, headers:) + end + + private + + # Devise session or Bearer token, falling back to a guest (unpersisted) user. + def mcp_user + return current_user if user_signed_in? + + token = bearer_token + authed = McpAuth.authenticate_bearer(token) if token + authed || User.new + end + + def bearer_token + match = request.headers['Authorization'].to_s.match(/\ABearer\s+(.+)\z/i) + match && match[1] + end +end diff --git a/app/mcp/mcp_auth.rb b/app/mcp/mcp_auth.rb new file mode 100644 index 00000000..4a7e45a3 --- /dev/null +++ b/app/mcp/mcp_auth.rb @@ -0,0 +1,9 @@ +# Bearer-token authentication for the MCP endpoint. +# +# TODO: verify ApiToken here (later lane). Every token is rejected for now, +# so bearer requests fall back to the guest user. +module McpAuth + def self.authenticate_bearer(_token) + nil + end +end diff --git a/app/mcp/server_info_tool.rb b/app/mcp/server_info_tool.rb new file mode 100644 index 00000000..af8e1ca8 --- /dev/null +++ b/app/mcp/server_info_tool.rb @@ -0,0 +1,25 @@ +# Read-only introspection for the Hackweek MCP server. Never returns secrets, +# only the calling user's own name/email plus public server facts. +class ServerInfoTool < MCP::Tool + tool_name 'server_info' + description 'Returns basic information about the Hackweek MCP server and the calling user.' + annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false) + + def self.call(server_context:) + # The SDK wraps our { user: } hash in MCP::ServerContext; [:user] reads through on both. + user = server_context[:user] + user = User.new unless user.is_a?(User) + + signed_in = user.persisted? + lines = ['Hackweek MCP server', "User: #{signed_in ? user.name : 'guest'}"] + # Guest responses must stay free of any email address. + lines << "Email: #{user.email}" if signed_in + lines << "Admin: #{signed_in && user.role?('admin')}" + lines << "Organizer: #{signed_in && user.role?('organizer')}" + lines << "Active episode: #{Episode.active&.name || 'none'}" + # The server owns the tool list; count tool files so later tools show up. + lines << "Tools: #{Rails.root.glob('app/mcp/*_tool.rb').count}" + + MCP::Tool::Response.new([{ type: 'text', text: lines.join("\n") }]) + end +end diff --git a/config/routes.rb b/config/routes.rb index b1645dcd..d63eea14 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -93,5 +93,7 @@ get 'about', to: 'about#show' get 'howto', to: 'about#show' + post '/mcp', to: 'mcp#create' + root 'about#index' end diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb new file mode 100644 index 00000000..bbd9e016 --- /dev/null +++ b/spec/requests/mcp_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe 'MCP endpoint', type: :request do + include Devise::Test::IntegrationHelpers + + let(:json_headers) do + { 'CONTENT_TYPE' => 'application/json', 'ACCEPT' => 'application/json, text/event-stream' } + end + + def mcp_post(payload) + post '/mcp', params: payload.to_json, headers: json_headers + end + + def result_body + response.parsed_body['result'] + end + + def tool_text + result_body['content'].map { |block| block['text'] }.join + end + + it 'answers the initialize handshake' do + mcp_post({ jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, + clientInfo: { name: 'spec', version: '0.1' } } }) + + expect(response).to have_http_status(:ok) + expect(result_body['serverInfo']['name']).to eq('hackweek') + end + + it 'lists tools including server_info' do + mcp_post({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) + + expect(response).to have_http_status(:ok) + names = result_body['tools'].pluck('name') + expect(names).to include('server_info') + end + + it 'calls server_info for a signed-in user' do + user = create(:user) + sign_in user + mcp_post({ jsonrpc: '2.0', id: 3, method: 'tools/call', + params: { name: 'server_info', arguments: {} } }) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include(user.name).and include(user.email) + expect(tool_text).to include('Active episode:') + end + + it 'reports the active episode title' do + episode = build(:episode, name: 'Hackweek 99') + allow(Episode).to receive(:active).and_return(episode) + text = ServerInfoTool.call(server_context: { user: User.new }).content.first[:text] + + expect(text).to include('Hackweek 99') + end + + it 'answers guests without leaking any email' do + create(:user, email: 'someone@example.com') + mcp_post({ jsonrpc: '2.0', id: 4, method: 'tools/call', + params: { name: 'server_info', arguments: {} } }) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include('guest') + expect(tool_text).not_to include('@') + end + + it 'rejects non-JSON posts with 415' do + post '/mcp', params: 'hello', headers: { 'CONTENT_TYPE' => 'text/plain' } + + expect(response).to have_http_status(:unsupported_media_type) + end +end From 90ee938661b5f565158dd6cfc7a51e7492a1a3ee Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:29:14 +0200 Subject: [PATCH 03/32] feat(mcp): add read-only project and episode tools ListProjects, GetProject, SearchProjects (LIKE-based, Sphinx upgrade noted in code) and ListEpisodes tools with uniform-denial authz and JSON-round-tripped specs. --- app/mcp/get_project_tool.rb | 65 +++++++++++++++++++++ app/mcp/list_episodes_tool.rb | 27 +++++++++ app/mcp/list_projects_tool.rb | 81 +++++++++++++++++++++++++++ app/mcp/search_projects_tool.rb | 48 ++++++++++++++++ spec/mcp/get_project_tool_spec.rb | 56 ++++++++++++++++++ spec/mcp/list_episodes_tool_spec.rb | 25 +++++++++ spec/mcp/list_projects_tool_spec.rb | 68 ++++++++++++++++++++++ spec/mcp/search_projects_tool_spec.rb | 45 +++++++++++++++ spec/mcp/spec_helpers.rb | 26 +++++++++ 9 files changed, 441 insertions(+) create mode 100644 app/mcp/get_project_tool.rb create mode 100644 app/mcp/list_episodes_tool.rb create mode 100644 app/mcp/list_projects_tool.rb create mode 100644 app/mcp/search_projects_tool.rb create mode 100644 spec/mcp/get_project_tool_spec.rb create mode 100644 spec/mcp/list_episodes_tool_spec.rb create mode 100644 spec/mcp/list_projects_tool_spec.rb create mode 100644 spec/mcp/search_projects_tool_spec.rb create mode 100644 spec/mcp/spec_helpers.rb diff --git a/app/mcp/get_project_tool.rb b/app/mcp/get_project_tool.rb new file mode 100644 index 00000000..b87255e1 --- /dev/null +++ b/app/mcp/get_project_tool.rb @@ -0,0 +1,65 @@ +class GetProjectTool < MCP::Tool + tool_name 'get_project' + description 'Show a hackweek project with members, keywords and readable updates.' + input_schema( + properties: { + id: { type: 'string', description: 'Project url slug (see the url field of list_projects).' } + }, + required: ['id'] + ) + annotations(read_only_hint: true) + + DENIED_MESSAGE = 'Not accessible through this server'.freeze + RECENT_UPDATE_LIMIT = 10 + + def self.call(id:, server_context:) + user = user_from(server_context) + ability = Ability.new(user) + project = find_project(id) + return denied_response if project.nil? || !ability.can?(:read, project) + + text_response(JSON.generate(serialize(project, ability))) + end + + class << self + private + + def user_from(server_context) + server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + end + + def find_project(ref) + scoped = Project.includes(:originator, :users, :keywords) + scoped.find_by(url: ref.to_s) || (Project.numeric?(ref) ? scoped.find_by(id: ref) : nil) + end + + def serialize(project, ability) + { + id: project.id, + title: project.title, + description: project.description, + state: project.aasm_state, + url: project.url, + originator: project.originator&.name, + members: project.users.map(&:name), + keywords: project.keywords.map(&:name), + recent_updates: readable_updates(project, ability) + } + end + + def readable_updates(project, ability) + updates = project.updates.order(created_at: :desc).limit(RECENT_UPDATE_LIMIT) + updates.select { |update| ability.can?(:read, update) }.map do |update| + { author: update.author&.name, text: update.text } + end + end + + def text_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }]) + end + + def denied_response + MCP::Tool::Response.new([{ type: 'text', text: DENIED_MESSAGE }], error: true) + end + end +end diff --git a/app/mcp/list_episodes_tool.rb b/app/mcp/list_episodes_tool.rb new file mode 100644 index 00000000..898c03a2 --- /dev/null +++ b/app/mcp/list_episodes_tool.rb @@ -0,0 +1,27 @@ +class ListEpisodesTool < MCP::Tool + tool_name 'list_episodes' + description 'List hackweek episodes with dates and the active flag.' + input_schema(properties: {}, required: []) + annotations(read_only_hint: true) + + def self.call(server_context:) + # Public listing; the context is accepted for a uniform tool interface. + _context = server_context + entries = Episode.order(:start_date, :id).map { |episode| serialize(episode) } + MCP::Tool::Response.new([{ type: 'text', text: JSON.generate(entries) }]) + end + + class << self + private + + def serialize(episode) + { + id: episode.id, + title: episode.name, + start_date: episode.start_date&.iso8601, + end_date: episode.end_date&.iso8601, + active: episode.active == true + } + end + end +end diff --git a/app/mcp/list_projects_tool.rb b/app/mcp/list_projects_tool.rb new file mode 100644 index 00000000..8cc57b91 --- /dev/null +++ b/app/mcp/list_projects_tool.rb @@ -0,0 +1,81 @@ +class ListProjectsTool < MCP::Tool + tool_name 'list_projects' + description 'List hackweek projects, scoped to the active episode by default.' + input_schema( + properties: { + episode: { + type: %w[string integer], + description: 'Episode id to scope to, or "all" for every episode. Defaults to the active episode.' + }, + limit: { + type: 'integer', + description: 'Maximum number of projects to return (default 20, max 100).' + } + }, + required: [] + ) + annotations(read_only_hint: true) + + DENIED_MESSAGE = 'Not accessible through this server'.freeze + DEFAULT_LIMIT = 20 + MAX_LIMIT = 100 + + def self.call(server_context:, episode: nil, limit: DEFAULT_LIMIT) + ability = Ability.new(user_from(server_context)) + projects = scope_to_episode(Project.includes(:originator).order(:id), episode) + return denied_response if projects.nil? + + visible = projects.limit(clamp_limit(limit)).select { |project| ability.can?(:read, project) } + text_response(JSON.generate(visible.map { |project| serialize(project) })) + end + + class << self + private + + def user_from(server_context) + server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + end + + def scope_to_episode(projects, episode) + return scope_to_active(projects) if episode.nil? || episode.to_s.strip.empty? + return projects if episode.to_s == 'all' + + found = Episode.find_by(id: episode) + return nil if found.nil? + + projects.by_episode(found) + end + + def scope_to_active(projects) + active = Episode.active + active ? projects.by_episode(active) : projects + end + + def clamp_limit(limit) + clamped = limit.to_i + return DEFAULT_LIMIT if clamped <= 0 + return MAX_LIMIT if clamped > MAX_LIMIT + + clamped + end + + def serialize(project) + { + id: project.id, + title: project.title, + url: project.url, + state: project.aasm_state, + originator: project.originator&.name, + member_count: project.memberships_count.to_i + } + end + + def text_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }]) + end + + def denied_response + MCP::Tool::Response.new([{ type: 'text', text: DENIED_MESSAGE }], error: true) + end + end +end diff --git a/app/mcp/search_projects_tool.rb b/app/mcp/search_projects_tool.rb new file mode 100644 index 00000000..04872fbb --- /dev/null +++ b/app/mcp/search_projects_tool.rb @@ -0,0 +1,48 @@ +class SearchProjectsTool < MCP::Tool + tool_name 'search_projects' + description 'Search hackweek projects by title, description and keywords.' + input_schema( + properties: { + query: { type: 'string', description: 'Free-text query matched against title, description and keywords.' } + }, + required: ['query'] + ) + annotations(read_only_hint: true) + + RESULT_LIMIT = 20 + + # NOTE: LIKE-based search only. Upgrade to ThinkingSphinx once searchd is + # available in the test env; the models already maintain real-time indices. + def self.call(query:, server_context:) + ability = Ability.new(user_from(server_context)) + matches = search(query.to_s).select { |project| ability.can?(:read, project) } + text_response(JSON.generate(matches.map { |project| serialize(project) })) + end + + class << self + private + + def user_from(server_context) + server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + end + + def search(query) + term = query.strip + return Project.none if term.empty? + + pattern = "%#{ActiveRecord::Base.sanitize_sql_like(term.downcase)}%" + conditions = ['LOWER(projects.title) LIKE :pattern', + 'LOWER(projects.description) LIKE :pattern', + 'LOWER(keywords.name) LIKE :pattern'].join(' OR ') + Project.left_joins(:keywords).where(conditions, pattern: pattern).distinct.order(:id).limit(RESULT_LIMIT) + end + + def serialize(project) + { id: project.id, title: project.title, url: project.url, state: project.aasm_state } + end + + def text_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }]) + end + end +end diff --git a/spec/mcp/get_project_tool_spec.rb b/spec/mcp/get_project_tool_spec.rb new file mode 100644 index 00000000..820a0c5f --- /dev/null +++ b/spec/mcp/get_project_tool_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' +require_relative 'spec_helpers' + +describe GetProjectTool do + let(:user) { create(:user) } + let(:project) { create(:project, originator: user) } + + before do + project.keywords << Keyword.create!(name: 'testing') + Update.create!(author: user, project: project, text: 'mine') + end + + it 'returns title, state, originator, members, keywords and updates' do + payload = response_payload(call_tool(described_class, { id: project.url }, user)) + + expect(payload).to include( + 'title' => project.title, + 'state' => project.aasm_state, + 'originator' => user.name, + 'keywords' => ['testing'] + ) + expect(payload['members']).to include(*project.users.map(&:name)) + expect(payload['recent_updates'].pluck('text')).to include('mine') + end + + it 'denies uniformly for an unknown id' do + expect(call_tool(described_class, { id: 'no-such-project' }, user)).to be_denied + end + + it 'hides updates authored by other users' do + other = create(:user) + Update.create!(author: other, project: project, text: 'theirs') + + payload = response_payload(call_tool(described_class, { id: project.url }, user)) + texts = payload['recent_updates'].pluck('text') + + expect(texts).to include('mine') + expect(texts).not_to include('theirs') + end + + it 'shows others updates to their author' do + other = create(:user) + Update.create!(author: other, project: project, text: 'theirs') + + payload = response_payload(call_tool(described_class, { id: project.url }, other)) + + expect(payload['recent_updates'].pluck('text')).to include('theirs') + end + + it 'is readable for guests but suppresses update texts' do + response = call_tool(described_class, { id: project.url }, User.new) + + expect(response.error?).to be(false) + expect(response_payload(response)['recent_updates']).to be_empty + end +end diff --git a/spec/mcp/list_episodes_tool_spec.rb b/spec/mcp/list_episodes_tool_spec.rb new file mode 100644 index 00000000..c004d06a --- /dev/null +++ b/spec/mcp/list_episodes_tool_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' +require_relative 'spec_helpers' + +describe ListEpisodesTool do + it 'lists episodes with dates and the active flag' do + create(:episode, name: 'old', start_date: '2020-01-01', end_date: '2020-01-07') + active = create(:active_episode, name: 'now') + + payload = response_payload(call_tool(described_class, {}, create(:user))) + flagged = payload.find { |entry| entry['id'] == active.id } + + expect(payload.pluck('title')).to include('old', 'now') + expect(flagged).to include('active' => true, 'start_date' => active.start_date.iso8601) + expect(payload.reject { |entry| entry['id'] == active.id }.pluck('active')).to all(be(false)) + end + + it 'is readable for guests' do + create(:episode) + + response = call_tool(described_class, {}, User.new) + + expect(response.error?).to be(false) + expect(response_payload(response).length).to eq(1) + end +end diff --git a/spec/mcp/list_projects_tool_spec.rb b/spec/mcp/list_projects_tool_spec.rb new file mode 100644 index 00000000..85803083 --- /dev/null +++ b/spec/mcp/list_projects_tool_spec.rb @@ -0,0 +1,68 @@ +require 'rails_helper' +require_relative 'spec_helpers' + +describe ListProjectsTool do + let(:user) { create(:user) } + + it 'lists projects scoped to the active episode by default' do + other_episode = create(:episode) + elsewhere = create(:idea) + elsewhere.episodes << other_episode + create(:active_episode) + active_project = create(:idea) + + payload = response_payload(call_tool(described_class, {}, user)) + + expect(payload.pluck('id')).to contain_exactly(active_project.id) + end + + it 'lists every project for episode all' do + create(:episode) + first = create(:idea) + second = create(:idea) + + payload = response_payload(call_tool(described_class, { episode: 'all' }, user)) + + expect(payload.pluck('id')).to contain_exactly(first.id, second.id) + end + + it 'scopes to the given episode id' do + other_episode = create(:episode) + elsewhere = create(:idea) + elsewhere.episodes << other_episode + create(:idea) + + payload = response_payload(call_tool(described_class, { episode: other_episode.id }, user)) + + expect(payload.pluck('id')).to contain_exactly(elsewhere.id) + end + + it 'denies uniformly for an unknown episode' do + create(:idea) + + expect(call_tool(described_class, { episode: 999_999 }, user)).to be_denied + end + + it 'caps the number of returned projects' do + create_list(:idea, 3) + + payload = response_payload(call_tool(described_class, { episode: 'all', limit: 2 }, user)) + + expect(payload.length).to eq(2) + end + + it 'returns id, title, url, state, originator and member count' do + project = create(:project) + + payload = response_payload(call_tool(described_class, { episode: 'all' }, User.new)) + + expect(payload.first).to include( + 'id' => project.id, + 'title' => project.title, + 'url' => project.url, + 'state' => project.aasm_state, + 'originator' => project.originator.name, + 'member_count' => 1 + ) + end +end diff --git a/spec/mcp/search_projects_tool_spec.rb b/spec/mcp/search_projects_tool_spec.rb new file mode 100644 index 00000000..14f9832b --- /dev/null +++ b/spec/mcp/search_projects_tool_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' +require_relative 'spec_helpers' + +describe SearchProjectsTool do + let(:user) { create(:user) } + + before do + create(:idea, title: 'Time machine for tacos', description: 'A plain project') + create(:idea, title: 'A plain project', description: 'Delicious taco catering service') + keyword_project = create(:idea, title: 'A plain project', description: 'Nothing special') + keyword_project.keywords << Keyword.create!(name: 'tacostand') + create(:idea, title: 'Unrelated work', description: 'Nothing to find here') + end + + it 'matches titles' do + payload = response_payload(call_tool(described_class, { query: 'time machine' }, user)) + + expect(payload.pluck('title')).to contain_exactly('Time machine for tacos') + end + + it 'matches descriptions' do + payload = response_payload(call_tool(described_class, { query: 'catering' }, user)) + + expect(payload.length).to eq(1) + end + + it 'matches keywords' do + payload = response_payload(call_tool(described_class, { query: 'tacostand' }, user)) + + expect(payload.length).to eq(1) + end + + it 'returns an empty list without matches' do + payload = response_payload(call_tool(described_class, { query: 'zzz-no-match' }, user)) + + expect(payload).to be_empty + end + + it 'is readable for guests' do + response = call_tool(described_class, { query: 'taco' }, User.new) + + expect(response.error?).to be(false) + expect(response_payload(response).length).to eq(3) + end +end diff --git a/spec/mcp/spec_helpers.rb b/spec/mcp/spec_helpers.rb new file mode 100644 index 00000000..9d9d5fe9 --- /dev/null +++ b/spec/mcp/spec_helpers.rb @@ -0,0 +1,26 @@ +module McpSpecHelpers + DENIED_MESSAGE = 'Not accessible through this server'.freeze + + def call_tool(tool, args, user) + delivered = JSON.parse(JSON.generate(args), symbolize_names: true) + tool.call(**delivered, server_context: { user: user }) + end + + def response_text(response) + response.content.map { |block| block[:text] || block['text'] }.join + end + + def response_payload(response) + JSON.parse(response_text(response)) + end + + def be_denied + satisfy('uniform denial') do |response| + response.error? && response_text(response) == DENIED_MESSAGE + end + end +end + +RSpec.configure do |config| + config.include McpSpecHelpers +end From 11e5ae818e51dc854e4e5697caa3ca3a86a76fc8 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:29:14 +0200 Subject: [PATCH 04/32] feat(mcp): add my_projects, create_update and join_project tools Member-scoped create_update via Update.create!, idempotent join via join!, uniform denial for guests and strangers, with specs. --- app/mcp/create_update_tool.rb | 59 +++++++++++++++++++++++++++++ app/mcp/join_project_tool.rb | 56 +++++++++++++++++++++++++++ app/mcp/my_projects_tool.rb | 43 +++++++++++++++++++++ spec/mcp/create_update_tool_spec.rb | 52 +++++++++++++++++++++++++ spec/mcp/join_project_tool_spec.rb | 43 +++++++++++++++++++++ spec/mcp/my_projects_tool_spec.rb | 30 +++++++++++++++ 6 files changed, 283 insertions(+) create mode 100644 app/mcp/create_update_tool.rb create mode 100644 app/mcp/join_project_tool.rb create mode 100644 app/mcp/my_projects_tool.rb create mode 100644 spec/mcp/create_update_tool_spec.rb create mode 100644 spec/mcp/join_project_tool_spec.rb create mode 100644 spec/mcp/my_projects_tool_spec.rb diff --git a/app/mcp/create_update_tool.rb b/app/mcp/create_update_tool.rb new file mode 100644 index 00000000..aa282e6c --- /dev/null +++ b/app/mcp/create_update_tool.rb @@ -0,0 +1,59 @@ +class CreateUpdateTool < MCP::Tool + tool_name 'create_update' + description 'Post an update to a hackweek project you originate or belong to.' + input_schema( + properties: { + project_id: { type: %w[string integer], description: 'Project id or url slug.' }, + text: { type: 'string', description: 'Update text.' } + }, + required: %w[project_id text] + ) + annotations(read_only_hint: false, destructive_hint: false) + + DENIED_MESSAGE = 'Not accessible through this server'.freeze + + def self.call(project_id:, text:, server_context:) + user = user_from(server_context) + return denied_response if guest?(user) + + project = find_project(project_id) + return denied_response if project.nil? || !member_or_originator?(project, user) + + update = Update.create!(author: user, project: project, text: text) + text_response("Created update #{update.id} on project '#{project.title}'.") + rescue ActiveRecord::RecordInvalid => e + error_response("Could not create update: #{e.record.errors.full_messages.to_sentence}.") + end + + class << self + private + + def user_from(server_context) + server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + end + + def guest?(user) + user.nil? || (user.respond_to?(:persisted?) && !user.persisted?) + end + + def find_project(ref) + Project.find_by(url: ref.to_s) || (Project.numeric?(ref) ? Project.find_by(id: ref) : nil) + end + + def member_or_originator?(project, user) + project.originator_id == user.id || project.users.include?(user) + end + + def text_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }]) + end + + def error_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }], error: true) + end + + def denied_response + MCP::Tool::Response.new([{ type: 'text', text: DENIED_MESSAGE }], error: true) + end + end +end diff --git a/app/mcp/join_project_tool.rb b/app/mcp/join_project_tool.rb new file mode 100644 index 00000000..36da320b --- /dev/null +++ b/app/mcp/join_project_tool.rb @@ -0,0 +1,56 @@ +class JoinProjectTool < MCP::Tool + tool_name 'join_project' + description 'Join a hackweek project as a member.' + input_schema( + properties: { + project_id: { type: %w[string integer], description: 'Project id or url slug.' } + }, + required: ['project_id'] + ) + annotations(read_only_hint: false, destructive_hint: false, idempotent_hint: true) + + DENIED_MESSAGE = 'Not accessible through this server'.freeze + + def self.call(project_id:, server_context:) + user = user_from(server_context) + return denied_response if guest?(user) + + project = find_project(project_id) + return denied_response if project.nil? + return text_response("Already a member of project '#{project.title}'.") if project.users.include?(user) + + if project.join!(user) + text_response("Joined project '#{project.title}'.") + else + error_response(project.errors.full_messages.to_sentence.presence || 'Could not join project.') + end + end + + class << self + private + + def user_from(server_context) + server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + end + + def guest?(user) + user.nil? || (user.respond_to?(:persisted?) && !user.persisted?) + end + + def find_project(ref) + Project.find_by(url: ref.to_s) || (Project.numeric?(ref) ? Project.find_by(id: ref) : nil) + end + + def text_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }]) + end + + def error_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }], error: true) + end + + def denied_response + MCP::Tool::Response.new([{ type: 'text', text: DENIED_MESSAGE }], error: true) + end + end +end diff --git a/app/mcp/my_projects_tool.rb b/app/mcp/my_projects_tool.rb new file mode 100644 index 00000000..385aa40c --- /dev/null +++ b/app/mcp/my_projects_tool.rb @@ -0,0 +1,43 @@ +class MyProjectsTool < MCP::Tool + tool_name 'my_projects' + description "List the calling user's originated and joined hackweek projects." + input_schema(properties: {}, required: []) + annotations(read_only_hint: true) + + DENIED_MESSAGE = 'Not accessible through this server'.freeze + + def self.call(server_context:) + user = user_from(server_context) + return denied_response if guest?(user) + + originated = user.originated_projects.order(:id) + membered = user.projects.where.not(id: originated.select(:id)).order(:id) + entries = originated.map { |project| serialize(project, 'originator') } + + membered.map { |project| serialize(project, 'member') } + text_response(JSON.generate(entries)) + end + + class << self + private + + def user_from(server_context) + server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + end + + def guest?(user) + user.nil? || (user.respond_to?(:persisted?) && !user.persisted?) + end + + def serialize(project, role) + { id: project.id, title: project.title, url: project.url, role: role } + end + + def text_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }]) + end + + def denied_response + MCP::Tool::Response.new([{ type: 'text', text: DENIED_MESSAGE }], error: true) + end + end +end diff --git a/spec/mcp/create_update_tool_spec.rb b/spec/mcp/create_update_tool_spec.rb new file mode 100644 index 00000000..9dacbc9b --- /dev/null +++ b/spec/mcp/create_update_tool_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' +require_relative 'spec_helpers' + +describe CreateUpdateTool do + let(:user) { create(:user) } + let(:project) { create(:idea, originator: user) } + + it 'creates an update for the originator' do + response = call_tool(described_class, { project_id: project.id, text: 'progress!' }, user) + + expect(response.error?).to be(false) + expect(project.updates.where(author: user, text: 'progress!').count).to eq(1) + end + + it 'creates an update for a member' do + membered = create(:project, users: [user]) + + response = call_tool(described_class, { project_id: membered.url, text: 'helping' }, user) + + expect(response.error?).to be(false) + end + + it 'denies uniformly for other users' do + stranger = create(:user) + denied_id = project.id + response = nil + expect do + response = call_tool(described_class, { project_id: denied_id, text: 'hijack' }, stranger) + end.not_to change(Update, :count) + + expect(response).to be_denied + end + + it 'denies uniformly for guests' do + expect(call_tool(described_class, { project_id: project.id, text: 'hi' }, User.new)).to be_denied + end + + it 'denies uniformly for unknown projects' do + expect(call_tool(described_class, { project_id: 999_999, text: 'hi' }, user)).to be_denied + end + + it 'returns an error response on validation failure' do + project_id = project.id + invalid = Update.new + invalid.validate + allow(Update).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(invalid)) + + response = call_tool(described_class, { project_id: project_id, text: 'boom' }, user) + + expect(response.error?).to be(true) + end +end diff --git a/spec/mcp/join_project_tool_spec.rb b/spec/mcp/join_project_tool_spec.rb new file mode 100644 index 00000000..3ee4d98e --- /dev/null +++ b/spec/mcp/join_project_tool_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' +require_relative 'spec_helpers' + +describe JoinProjectTool do + let(:user) { create(:user) } + let(:project) { create(:idea) } + + it 'joins the project' do + response = call_tool(described_class, { project_id: project.id }, user) + + expect(response.error?).to be(false) + expect(project.reload.users).to include(user) + end + + it 'treats re-joining as idempotent success' do + call_tool(described_class, { project_id: project.id }, user) + + response = nil + expect do + response = call_tool(described_class, { project_id: project.id }, user) + end.not_to(change { project.reload.memberships_count }) + + expect(response.error?).to be(false) + expect(response_text(response)).to match(/already a member/i) + end + + it 'denies uniformly for guests' do + expect(call_tool(described_class, { project_id: project.id }, User.new)).to be_denied + end + + it 'denies uniformly for unknown projects' do + expect(call_tool(described_class, { project_id: 999_999 }, user)).to be_denied + end + + it 'returns an error response for finished projects' do + finished = create(:invention) + + response = call_tool(described_class, { project_id: finished.id }, user) + + expect(response.error?).to be(true) + expect(finished.reload.users).not_to include(user) + end +end diff --git a/spec/mcp/my_projects_tool_spec.rb b/spec/mcp/my_projects_tool_spec.rb new file mode 100644 index 00000000..de7f8e6d --- /dev/null +++ b/spec/mcp/my_projects_tool_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' +require_relative 'spec_helpers' + +describe MyProjectsTool do + let(:user) { create(:user) } + + it 'denies uniformly for guests' do + expect(call_tool(described_class, {}, User.new)).to be_denied + end + + it 'returns originated and member projects with roles' do + originated = create(:idea, originator: user) + other = create(:user) + membered = create(:project, originator: other, users: [user]) + create(:idea, originator: other) + + payload = response_payload(call_tool(described_class, {}, user)) + by_id = payload.index_by { |entry| entry['id'] } + + expect(by_id.keys).to contain_exactly(originated.id, membered.id) + expect(by_id[originated.id]).to include('title' => originated.title, 'role' => 'originator') + expect(by_id[membered.id]['role']).to eq('member') + end + + it 'returns an empty list without projects' do + payload = response_payload(call_tool(described_class, {}, user)) + + expect(payload).to be_empty + end +end From 907442d50b1e3e79785287acd4e7b02c04b19f7a Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:29:49 +0200 Subject: [PATCH 05/32] fix(mcp): read user off ServerContext as well as Hash --- app/mcp/create_update_tool.rb | 2 +- app/mcp/get_project_tool.rb | 2 +- app/mcp/join_project_tool.rb | 2 +- app/mcp/list_projects_tool.rb | 2 +- app/mcp/my_projects_tool.rb | 2 +- app/mcp/search_projects_tool.rb | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/mcp/create_update_tool.rb b/app/mcp/create_update_tool.rb index aa282e6c..c864ab30 100644 --- a/app/mcp/create_update_tool.rb +++ b/app/mcp/create_update_tool.rb @@ -29,7 +29,7 @@ class << self private def user_from(server_context) - server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + server_context[:user] # Hash and MCP::ServerContext both answer #[] end def guest?(user) diff --git a/app/mcp/get_project_tool.rb b/app/mcp/get_project_tool.rb index b87255e1..e172462e 100644 --- a/app/mcp/get_project_tool.rb +++ b/app/mcp/get_project_tool.rb @@ -25,7 +25,7 @@ class << self private def user_from(server_context) - server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + server_context[:user] # Hash and MCP::ServerContext both answer #[] end def find_project(ref) diff --git a/app/mcp/join_project_tool.rb b/app/mcp/join_project_tool.rb index 36da320b..fbe0863b 100644 --- a/app/mcp/join_project_tool.rb +++ b/app/mcp/join_project_tool.rb @@ -30,7 +30,7 @@ class << self private def user_from(server_context) - server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + server_context[:user] # Hash and MCP::ServerContext both answer #[] end def guest?(user) diff --git a/app/mcp/list_projects_tool.rb b/app/mcp/list_projects_tool.rb index 8cc57b91..59882efd 100644 --- a/app/mcp/list_projects_tool.rb +++ b/app/mcp/list_projects_tool.rb @@ -33,7 +33,7 @@ class << self private def user_from(server_context) - server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + server_context[:user] # Hash and MCP::ServerContext both answer #[] end def scope_to_episode(projects, episode) diff --git a/app/mcp/my_projects_tool.rb b/app/mcp/my_projects_tool.rb index 385aa40c..be7afd7a 100644 --- a/app/mcp/my_projects_tool.rb +++ b/app/mcp/my_projects_tool.rb @@ -21,7 +21,7 @@ class << self private def user_from(server_context) - server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + server_context[:user] # Hash and MCP::ServerContext both answer #[] end def guest?(user) diff --git a/app/mcp/search_projects_tool.rb b/app/mcp/search_projects_tool.rb index 04872fbb..8ffb96e0 100644 --- a/app/mcp/search_projects_tool.rb +++ b/app/mcp/search_projects_tool.rb @@ -23,7 +23,7 @@ class << self private def user_from(server_context) - server_context.is_a?(Hash) ? server_context[:user] || server_context['user'] : nil + server_context[:user] # Hash and MCP::ServerContext both answer #[] end def search(query) From f27a2cb254824384b02248450875acd2478013c4 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:34:52 +0200 Subject: [PATCH 06/32] feat(mcp): add ApiToken model with digest-stored secrets --- app/models/api_token.rb | 52 ++++++++++++++ .../20261012000001_create_api_tokens.rb | 15 ++++ db/schema.rb | 16 ++++- spec/models/api_token_spec.rb | 69 +++++++++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 app/models/api_token.rb create mode 100644 db/migrate/20261012000001_create_api_tokens.rb create mode 100644 spec/models/api_token_spec.rb diff --git a/app/models/api_token.rb b/app/models/api_token.rb new file mode 100644 index 00000000..0a8e8445 --- /dev/null +++ b/app/models/api_token.rb @@ -0,0 +1,52 @@ +# Bearer secrets for the MCP endpoint. Only the SHA256 digest hits the +# database; the raw token is exposed once via #raw_token right after mint. +class ApiToken < ApplicationRecord + belongs_to :user + + SCOPES = %w[read write].freeze + + validates :name, presence: true + validates :scope, presence: true, inclusion: { in: SCOPES } + validates :token_digest, presence: true, uniqueness: true + + attr_reader :raw_token + + before_validation :mint_secret, on: :create + + def self.digest(raw) + Digest::SHA256.hexdigest(raw.to_s) + end + + # Returns the live record for a presented secret, nil when unknown/revoked. + def self.authenticate(raw) + return nil if raw.blank? + + expected = digest(raw) + candidate = find_by(token_digest: expected) + return nil if candidate.nil? || candidate.revoked? + return nil unless ActiveSupport::SecurityUtils.secure_compare(candidate.token_digest, expected) + + candidate + end + + def revoked? + revoked_at.present? + end + + def revoke! + update!(revoked_at: Time.current) + end + + def write? + scope == 'write' + end + + private + + def mint_secret + return if token_digest.present? + + @raw_token = SecureRandom.hex(32) + self.token_digest = self.class.digest(@raw_token) + end +end diff --git a/db/migrate/20261012000001_create_api_tokens.rb b/db/migrate/20261012000001_create_api_tokens.rb new file mode 100644 index 00000000..eb317ae5 --- /dev/null +++ b/db/migrate/20261012000001_create_api_tokens.rb @@ -0,0 +1,15 @@ +class CreateApiTokens < ActiveRecord::Migration[7.2] + def change + create_table :api_tokens do |t| + t.references :user, null: false, foreign_key: true, type: :integer + t.string :name, null: false + t.string :scope, null: false, default: 'read' + t.string :token_digest, null: false + t.datetime :last_used_at + t.datetime :revoked_at + + t.timestamps + end + add_index :api_tokens, :token_digest, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8eba1474..78050763 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_10_21_103843) do +ActiveRecord::Schema[7.2].define(version: 2026_10_12_000001) do create_table "active_storage_attachments", charset: "utf8", force: :cascade do |t| t.string "name", null: false @@ -48,6 +48,19 @@ t.datetime "updated_at" end + create_table "api_tokens", force: :cascade do |t| + t.integer "user_id", null: false + t.string "name", null: false + t.string "scope", default: "read", null: false + t.string "token_digest", null: false + t.datetime "last_used_at" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token_digest"], name: "index_api_tokens_on_token_digest", unique: true + t.index ["user_id"], name: "index_api_tokens_on_user_id" + end + create_table "comments", id: :integer, charset: "utf8", collation: "utf8_bin", options: "ENGINE=InnoDB ROW_FORMAT=DYNAMIC", force: :cascade do |t| t.text "text" t.integer "commentable_id" @@ -233,4 +246,5 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "api_tokens", "users" end diff --git a/spec/models/api_token_spec.rb b/spec/models/api_token_spec.rb new file mode 100644 index 00000000..47edc559 --- /dev/null +++ b/spec/models/api_token_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +describe ApiToken do + let(:user) { create(:user) } + + it 'belongs to a user and defaults to read scope' do + token = described_class.create!(user: user, name: 'spec') + + expect(token.scope).to eq('read') + expect(token.user).to eq(user) + end + + it 'requires a name and a known scope' do + expect(described_class.new(user: user, name: nil).valid?).to be(false) + expect(described_class.new(user: user, name: 'spec', scope: 'admin').valid?).to be(false) + end + + it 'shows the raw secret once and stores only its digest' do + token = described_class.create!(user: user, name: 'spec', scope: 'write') + raw = token.raw_token + + expect(raw).to be_present + expect(token.token_digest).to eq(described_class.digest(raw)) + expect(token.token_digest).not_to eq(raw) + expect(described_class.find(token.id).raw_token).to be_nil + end + + it 'never persists the raw secret' do + token = described_class.create!(user: user, name: 'spec') + raw = token.raw_token + stored = ActiveRecord::Base.connection.select_all('SELECT * FROM api_tokens').to_a + + expect(stored.flat_map(&:values)).not_to include(raw) + end + + it 'authenticates a live token' do + token = described_class.create!(user: user, name: 'spec') + found = described_class.authenticate(token.raw_token) + + expect(found).to eq(token) + end + + it 'rejects unknown secrets' do + described_class.create!(user: user, name: 'spec') + + expect(described_class.authenticate('no-such-token')).to be_nil + expect(described_class.authenticate(nil)).to be_nil + expect(described_class.authenticate('')).to be_nil + end + + it 'rejects revoked tokens' do + token = described_class.create!(user: user, name: 'spec') + raw = token.raw_token + token.revoke! + + expect(token.revoked?).to be(true) + expect(described_class.authenticate(raw)).to be_nil + end + + it 'compares digests in constant time' do + token = described_class.create!(user: user, name: 'spec') + raw = token.raw_token + allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).and_call_original + + described_class.authenticate(raw) + + expect(ActiveSupport::SecurityUtils).to have_received(:secure_compare) + end +end From 78d67ec66ce567573284338cac1f8db243ce7b22 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:53:50 +0200 Subject: [PATCH 07/32] feat(mcp): add API token mint/revoke UI to user settings --- app/controllers/users_controller.rb | 29 +++++++++++++++++++++- app/models/ability.rb | 1 + app/views/users/_tokens.html.haml | 37 +++++++++++++++++++++++++++++ app/views/users/edit.html.haml | 8 +++++++ app/views/users/tokens.html.haml | 24 +++++++++++++++++++ config/routes.rb | 3 +++ 6 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 app/views/users/_tokens.html.haml create mode 100644 app/views/users/tokens.html.haml diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d87ae898..a4c999e5 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -15,7 +15,9 @@ def index end end - def edit; end + def edit + @api_tokens = owner_tokens + end def update @user.update(params['user']) @@ -59,8 +61,33 @@ def delete_keyword redirect_to @user, notice: "Keyword '#{params[:keyword]}' removed." end + def tokens + @api_tokens = owner_tokens + end + + def create_token + @token = ApiToken.new(user: @user, name: params[:name], scope: params[:scope]) + @api_tokens = owner_tokens + if @token.save + @raw_token = @token.raw_token + render :tokens + else + render :tokens, status: :unprocessable_content + end + end + + def revoke_token + token = ApiToken.find_by!(id: params[:token_id], user_id: @user.id) + token.revoke! + redirect_back_or_to tokens_user_path(@user), notice: "Token '#{token.name}' revoked." + end + private + def owner_tokens + ApiToken.where(user: @user).order(created_at: :desc) + end + def user_params params.require(:user).permit(:location, :hide_email) end diff --git a/app/models/ability.rb b/app/models/ability.rb index 9393ea30..18d8e36a 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -32,6 +32,7 @@ def initialize(user) can [:enroll], Announcement # ...change things they own can %i[update add_keyword delete_keyword], User, id: user.id + can %i[tokens create_token revoke_token], User, id: user.id can :read, Update, author_id: user.id can :manage, Project, originator_id: user.id can %i[create update], Comment, commenter_id: user.id diff --git a/app/views/users/_tokens.html.haml b/app/views/users/_tokens.html.haml new file mode 100644 index 00000000..090d98b9 --- /dev/null +++ b/app/views/users/_tokens.html.haml @@ -0,0 +1,37 @@ +%table.table.table-striped + %thead + %tr + %th Name + %th Scope + %th Last used + %th Created + %th + %tbody + - if api_tokens.empty? + %tr + %td{ colspan: 5 } No API tokens yet. + - api_tokens.each do |token| + %tr + %td= token.name + %td= token.scope + %td= token.last_used_at ? l(token.last_used_at, format: :short) : 'never' + %td= l(token.created_at, format: :short) + %td + - if token.revoked? + %span.text-muted revoked + - else + = button_to 'Revoke', tokens_user_path(user, token_id: token.id), method: :delete, class: 'btn btn-danger btn-xs', data: { confirm: 'Revoke this token?' } + +%h4 Mint a new token += form_tag tokens_user_path(user), method: :post, class: 'form-horizontal' do + .form-group + = label_tag :name, 'Name', class: 'col-sm-2 control-label' + .col-sm-10 + = text_field_tag :name, nil, placeholder: 'e.g. my MCP client', class: 'form-control', required: true + .form-group + = label_tag :scope, 'Scope', class: 'col-sm-2 control-label' + .col-sm-10 + = select_tag :scope, options_for_select([['Read-only', 'read'], ['Read + write', 'write']]), class: 'form-control' + .form-group + .col-sm-offset-2.col-sm-10 + = submit_tag 'Mint token', class: 'btn btn-default' diff --git a/app/views/users/edit.html.haml b/app/views/users/edit.html.haml index 7a30fdd3..787c7710 100644 --- a/app/views/users/edit.html.haml +++ b/app/views/users/edit.html.haml @@ -30,3 +30,11 @@ .form-group .col-sm-offset-2.col-sm-10 = f.submit :class => "btn btn-default" + +- if can? :tokens, @user + %hr + .row + .col-md-12 + %h3 API tokens + %p.text-muted Tokens let MCP clients act as you. Secrets are shown once at mint time. + = render partial: 'tokens', locals: { user: @user, api_tokens: @api_tokens } diff --git a/app/views/users/tokens.html.haml b/app/views/users/tokens.html.haml new file mode 100644 index 00000000..5104fb9c --- /dev/null +++ b/app/views/users/tokens.html.haml @@ -0,0 +1,24 @@ +- content_for :title do + API tokens + = @user.name + +.row + .col-md-12 + %h2 + API tokens for + = @user.name + - if @raw_token + .alert.alert-warning + %p + %strong Copy your new token now, it will not be shown again: + %code= @raw_token + - if @token && @token.errors.any? + #error_explanation + %h2 + = pluralize(@token.errors.count, "error") + prohibited this token from being saved: + %ul + - @token.errors.full_messages.each do |msg| + %li + = msg + = render partial: 'tokens', locals: { user: @user, api_tokens: @api_tokens } diff --git a/config/routes.rb b/config/routes.rb index d63eea14..e2292a84 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,9 @@ get 'opportunities' post 'keyword', to: 'users#add_keyword' delete 'keyword', to: 'users#delete_keyword', constraints: { keyword: %r{[^/]+} } + get 'tokens', to: 'users#tokens' + post 'tokens', to: 'users#create_token' + delete 'tokens', to: 'users#revoke_token' end end From 362824fc9e88b7bd1543643f44fc8a1fed74347f Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:56:50 +0200 Subject: [PATCH 08/32] feat(mcp): wire bearer auth and write-scope enforcement --- app/controllers/mcp_controller.rb | 33 ++++++++++++++++++++++++++++++- app/mcp/mcp_auth.rb | 17 +++++++++++----- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index c5449a7c..04b87170 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -1,4 +1,11 @@ require_relative '../mcp/server_info_tool' +require_relative '../mcp/list_projects_tool' +require_relative '../mcp/get_project_tool' +require_relative '../mcp/search_projects_tool' +require_relative '../mcp/list_episodes_tool' +require_relative '../mcp/my_projects_tool' +require_relative '../mcp/create_update_tool' +require_relative '../mcp/join_project_tool' require_relative '../mcp/mcp_auth' # JSON-RPC endpoint for the Model Context Protocol (Streamable HTTP, stateless). @@ -7,13 +14,19 @@ class McpController < ApplicationController skip_before_action :authenticate_user! skip_forgery_protection only: [:create] + TOOLS = [ServerInfoTool, ListProjectsTool, GetProjectTool, SearchProjectsTool, + ListEpisodesTool, MyProjectsTool, CreateUpdateTool, JoinProjectTool].freeze + WRITE_TOOLS = %w[create_update join_project].freeze + DENIED_MESSAGE = 'Not accessible through this server'.freeze + def create return head :unsupported_media_type unless request.media_type == 'application/json' + return render_write_denied if write_denied? server = MCP::Server.new( name: 'hackweek', version: '1.0.0', - tools: [ServerInfoTool], + tools: TOOLS, server_context: { user: mcp_user } ) transport_options = { stateless: true, serve_subscriptions_listen: false } @@ -37,6 +50,24 @@ def mcp_user authed || User.new end + # Write tools need a write-scoped token; the session carries full agency. + # Denied here so read-scoped callers get the same uniform denial as guests. + def write_denied? + return false if user_signed_in? + return false unless params[:method] == 'tools/call' + return false unless WRITE_TOOLS.include?(params.dig(:params, :name)) + + McpAuth.token_scope(bearer_token) != 'write' + end + + def render_write_denied + render json: { + jsonrpc: '2.0', + id: params[:id], + result: { content: [{ type: 'text', text: DENIED_MESSAGE }], isError: true } + } + end + def bearer_token match = request.headers['Authorization'].to_s.match(/\ABearer\s+(.+)\z/i) match && match[1] diff --git a/app/mcp/mcp_auth.rb b/app/mcp/mcp_auth.rb index 4a7e45a3..f0782ec7 100644 --- a/app/mcp/mcp_auth.rb +++ b/app/mcp/mcp_auth.rb @@ -1,9 +1,16 @@ # Bearer-token authentication for the MCP endpoint. -# -# TODO: verify ApiToken here (later lane). Every token is rejected for now, -# so bearer requests fall back to the guest user. module McpAuth - def self.authenticate_bearer(_token) - nil + # Returns the token-owning user, nil for missing/unknown/revoked secrets. + def self.authenticate_bearer(token) + api_token = ApiToken.authenticate(token) + return nil if api_token.nil? + + api_token.update!(last_used_at: Time.current) + api_token.user + end + + # Effective scope of the presented secret, nil when unauthenticated. + def self.token_scope(token) + ApiToken.authenticate(token)&.scope end end From f2c12f508bf687c54481c9a27392dc4c09f3c3cb Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 19:56:50 +0200 Subject: [PATCH 09/32] test(mcp): cover bearer auth for all tools --- spec/requests/mcp_spec.rb | 202 +++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 2 deletions(-) diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index bbd9e016..c16a0cea 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -7,8 +7,12 @@ { 'CONTENT_TYPE' => 'application/json', 'ACCEPT' => 'application/json, text/event-stream' } end - def mcp_post(payload) - post '/mcp', params: payload.to_json, headers: json_headers + def mcp_post(payload, headers = json_headers) + post '/mcp', params: payload.to_json, headers: headers + end + + def bearer_headers(raw) + json_headers.merge('Authorization' => "Bearer #{raw}") end def result_body @@ -19,6 +23,14 @@ def tool_text result_body['content'].map { |block| block['text'] }.join end + def denied_result? + result_body['isError'] == true && tool_text == 'Not accessible through this server' + end + + def response_payload + JSON.parse(tool_text) + end + it 'answers the initialize handshake' do mcp_post({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, @@ -70,4 +82,190 @@ def tool_text expect(response).to have_http_status(:unsupported_media_type) end + + context 'with bearer tokens' do + let(:user) { create(:user) } + let(:read_token) { ApiToken.create!(user: user, name: 'read', scope: 'read') } + let(:write_token) { ApiToken.create!(user: user, name: 'write', scope: 'write') } + let(:read_raw) { read_token.raw_token } + let(:write_raw) { write_token.raw_token } + + def tool_call(name, args, raw, id: 100) + mcp_post({ jsonrpc: '2.0', id: id, method: 'tools/call', + params: { name: name, arguments: args } }, bearer_headers(raw)) + end + + def expect_no_token_leak(raw) + expect(response.body).not_to include(raw) + expect(response.body).not_to include(ApiToken.digest(raw)) + end + + it 'lists all eight wired tools' do + mcp_post({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }, + bearer_headers(read_raw)) + + expect(response).to have_http_status(:ok) + names = result_body['tools'].pluck('name') + expect(names).to contain_exactly('server_info', 'list_projects', 'get_project', + 'search_projects', 'list_episodes', 'my_projects', + 'create_update', 'join_project') + expect_no_token_leak(read_raw) + end + + it 'calls server_info as the token owner and records usage' do + tool_call('server_info', {}, read_raw) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include(user.name).and include(user.email) + expect(read_token.reload.last_used_at).to be_present + expect_no_token_leak(read_raw) + end + + it 'lists projects' do + project = create(:idea) + + tool_call('list_projects', { episode: 'all' }, read_raw) + + expect(response).to have_http_status(:ok) + expect(response_payload.pluck('id')).to include(project.id) + expect_no_token_leak(read_raw) + end + + it 'shows a project' do + project = create(:idea, originator: user) + + tool_call('get_project', { id: project.url }, read_raw) + + expect(response).to have_http_status(:ok) + expect(response_payload).to include('title' => project.title) + expect_no_token_leak(read_raw) + end + + it 'searches projects' do + project = create(:idea, title: 'Ferret sanctuary rota') + + tool_call('search_projects', { query: 'ferret' }, read_raw) + + expect(response).to have_http_status(:ok) + expect(response_payload.pluck('id')).to include(project.id) + expect_no_token_leak(read_raw) + end + + it 'lists episodes' do + create(:episode, name: 'Ep999') + + tool_call('list_episodes', {}, read_raw) + + expect(response).to have_http_status(:ok) + expect(response_payload.pluck('title')).to include('Ep999') + expect_no_token_leak(read_raw) + end + + it "returns the token owner's projects, proving the real user is carried" do + originated = create(:idea, originator: user) + create(:idea) + + tool_call('my_projects', {}, read_raw) + + expect(response).to have_http_status(:ok) + by_id = response_payload.index_by { |entry| entry['id'] } + expect(by_id.keys).to contain_exactly(originated.id) + expect(by_id[originated.id]).to include('title' => originated.title, 'role' => 'originator') + expect_no_token_leak(read_raw) + end + + it 'creates an update with a write token' do + project = create(:idea, originator: user) + + expect do + tool_call('create_update', { project_id: project.id, text: 'bearer progress' }, write_raw) + end.to change(Update, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include("project '#{project.title}'") + expect_no_token_leak(write_raw) + end + + it 'joins a project with a write token' do + project = create(:idea) + + tool_call('join_project', { project_id: project.id }, write_raw) + + expect(response).to have_http_status(:ok) + expect(project.reload.users).to include(user) + expect_no_token_leak(write_raw) + end + + it 'denies write tools to read-scoped tokens exactly like guests' do + project = create(:idea, originator: user) + payload = { jsonrpc: '2.0', id: 42, method: 'tools/call', + params: { name: 'create_update', + arguments: { project_id: project.id, text: 'hijack' } } } + + mcp_post(payload) + guest_result = result_body + + mcp_post(payload, bearer_headers(read_raw)) + + expect(response).to have_http_status(:ok) + expect(denied_result?).to be(true) + expect(result_body).to eq(guest_result) + expect_no_token_leak(read_raw) + end + + it 'creates no update for read-scoped write calls' do + project = create(:idea, originator: user) + payload = { jsonrpc: '2.0', id: 43, method: 'tools/call', + params: { name: 'create_update', + arguments: { project_id: project.id, text: 'hijack' } } } + + expect do + mcp_post(payload, bearer_headers(read_raw)) + end.not_to change(Update, :count) + + expect(denied_result?).to be(true) + end + + it 'denies joining projects to read-scoped tokens' do + project = create(:idea) + + tool_call('join_project', { project_id: project.id }, read_raw) + + expect(response).to have_http_status(:ok) + expect(denied_result?).to be(true) + expect(project.reload.users).not_to include(user) + expect_no_token_leak(read_raw) + end + + it 'treats revoked tokens as guests' do + raw = read_raw + read_token.revoke! + + tool_call('server_info', {}, raw) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include('guest') + expect(tool_text).not_to include('@') + expect(read_token.reload.last_used_at).to be_nil + expect_no_token_leak(raw) + end + + it 'treats unknown tokens as guests' do + tool_call('server_info', {}, SecureRandom.hex(32)) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include('guest') + expect(tool_text).not_to include('@') + end + + it 'keeps token material out of responses and logs' do + project = create(:idea, originator: user) + tool_call('create_update', { project_id: project.id, text: 'quiet' }, write_raw) + + expect(response).to have_http_status(:ok) + expect_no_token_leak(write_raw) + log_path = Rails.root.join('log/test.log') + expect(File.read(log_path, mode: 'r:UTF-8')).not_to include(write_raw) if File.exist?(log_path) + end + end end From 6210168b3cd70c4e8e1c7fe820e1ecb7034df9a4 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:00:44 +0200 Subject: [PATCH 10/32] docs(mcp): document the MCP endpoint and API tokens --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 3cc3c7c7..470ab110 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,15 @@ get you up and running. "Remote" connection to the container is available with `docker-compose exec hackweek /bin/bash`. You can run single-shot remote commands like `docker-compose exec hackweek rake db:migrate`. +## MCP +The app exposes a [Model Context Protocol](https://modelcontextprotocol.io/) endpoint at +`POST /mcp` (Streamable HTTP, stateless): project/episode search and reads, plus posting +project updates and joining projects — every call authorized as the calling user. + +1. Log in, open your profile settings and mint an API token (read or write scope). +2. Point any MCP client at `https://hackweek.opensuse.org/mcp` with header + `Authorization: Bearer `. + ## Resources * Design mockups of the Rails app are in the design directory. * There are some tools in the tool directory. From 10c1c4a54b2e2177ed92682527126baef65b6c8d Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:38:37 +0200 Subject: [PATCH 11/32] fix(mcp): constrain token digest and harden ApiToken lifecycle --- app/models/api_token.rb | 10 ++++++---- app/models/user.rb | 2 ++ db/migrate/20261012000001_create_api_tokens.rb | 2 +- db/schema.rb | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/models/api_token.rb b/app/models/api_token.rb index 0a8e8445..cbe6f479 100644 --- a/app/models/api_token.rb +++ b/app/models/api_token.rb @@ -5,7 +5,7 @@ class ApiToken < ApplicationRecord SCOPES = %w[read write].freeze - validates :name, presence: true + validates :name, presence: true, length: { maximum: 255 } validates :scope, presence: true, inclusion: { in: SCOPES } validates :token_digest, presence: true, uniqueness: true @@ -22,8 +22,8 @@ def self.authenticate(raw) return nil if raw.blank? expected = digest(raw) - candidate = find_by(token_digest: expected) - return nil if candidate.nil? || candidate.revoked? + candidate = where(token_digest: expected, revoked_at: nil).first + return nil if candidate.nil? return nil unless ActiveSupport::SecurityUtils.secure_compare(candidate.token_digest, expected) candidate @@ -34,6 +34,8 @@ def revoked? end def revoke! + return if revoked? + update!(revoked_at: Time.current) end @@ -44,7 +46,7 @@ def write? private def mint_secret - return if token_digest.present? + return if @raw_token.present? @raw_token = SecureRandom.hex(32) self.token_digest = self.class.digest(@raw_token) diff --git a/app/models/user.rb b/app/models/user.rb index d78bdca0..f66a888a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -26,6 +26,8 @@ class User < ApplicationRecord has_many :notifications, foreign_key: :recipient_id + has_many :api_tokens, dependent: :destroy + has_and_belongs_to_many :roles ThinkingSphinx::Callbacks.append(self, behaviours: [:real_time]) diff --git a/db/migrate/20261012000001_create_api_tokens.rb b/db/migrate/20261012000001_create_api_tokens.rb index eb317ae5..9d021a9f 100644 --- a/db/migrate/20261012000001_create_api_tokens.rb +++ b/db/migrate/20261012000001_create_api_tokens.rb @@ -4,7 +4,7 @@ def change t.references :user, null: false, foreign_key: true, type: :integer t.string :name, null: false t.string :scope, null: false, default: 'read' - t.string :token_digest, null: false + t.string :token_digest, null: false, limit: 64 t.datetime :last_used_at t.datetime :revoked_at diff --git a/db/schema.rb b/db/schema.rb index 78050763..ee34b960 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -52,7 +52,7 @@ t.integer "user_id", null: false t.string "name", null: false t.string "scope", default: "read", null: false - t.string "token_digest", null: false + t.string "token_digest", limit: 64, null: false t.datetime "last_used_at" t.datetime "revoked_at" t.datetime "created_at", null: false From ab4dcba597a90fdf6a0957f6f5e27ffa234797fb Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:39:09 +0200 Subject: [PATCH 12/32] fix(mcp): strip bearer whitespace before digest --- app/mcp/mcp_auth.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/mcp/mcp_auth.rb b/app/mcp/mcp_auth.rb index f0782ec7..04e620bc 100644 --- a/app/mcp/mcp_auth.rb +++ b/app/mcp/mcp_auth.rb @@ -2,7 +2,7 @@ module McpAuth # Returns the token-owning user, nil for missing/unknown/revoked secrets. def self.authenticate_bearer(token) - api_token = ApiToken.authenticate(token) + api_token = ApiToken.authenticate(token.to_s.strip) return nil if api_token.nil? api_token.update!(last_used_at: Time.current) @@ -11,6 +11,6 @@ def self.authenticate_bearer(token) # Effective scope of the presented secret, nil when unauthenticated. def self.token_scope(token) - ApiToken.authenticate(token)&.scope + ApiToken.authenticate(token.to_s.strip)&.scope end end From 8947b10518d6c395fb2134695a1c373b074588f0 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:40:12 +0200 Subject: [PATCH 13/32] fix(mcp): gate CSRF skip on bearer and harden write check --- app/controllers/mcp_controller.rb | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 04b87170..6979c439 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -1,18 +1,8 @@ -require_relative '../mcp/server_info_tool' -require_relative '../mcp/list_projects_tool' -require_relative '../mcp/get_project_tool' -require_relative '../mcp/search_projects_tool' -require_relative '../mcp/list_episodes_tool' -require_relative '../mcp/my_projects_tool' -require_relative '../mcp/create_update_tool' -require_relative '../mcp/join_project_tool' -require_relative '../mcp/mcp_auth' - # JSON-RPC endpoint for the Model Context Protocol (Streamable HTTP, stateless). class McpController < ApplicationController skip_before_action :store_location, :load_news, :set_episode, :load_notifications skip_before_action :authenticate_user! - skip_forgery_protection only: [:create] + skip_forgery_protection only: [:create], if: -> { bearer_token.present? } TOOLS = [ServerInfoTool, ListProjectsTool, GetProjectTool, SearchProjectsTool, ListEpisodesTool, MyProjectsTool, CreateUpdateTool, JoinProjectTool].freeze @@ -55,7 +45,10 @@ def mcp_user def write_denied? return false if user_signed_in? return false unless params[:method] == 'tools/call' - return false unless WRITE_TOOLS.include?(params.dig(:params, :name)) + + inner = params[:params] + return false unless inner.is_a?(Hash) || inner.is_a?(ActionController::Parameters) + return false unless WRITE_TOOLS.include?(inner[:name]) McpAuth.token_scope(bearer_token) != 'write' end From 5324b58139c0a566580b887a8fe18a25954167f8 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:41:38 +0200 Subject: [PATCH 14/32] fix(mcp): enforce write scope defense-in-depth --- app/controllers/mcp_controller.rb | 9 ++++++++- app/mcp/create_update_tool.rb | 1 + app/mcp/join_project_tool.rb | 1 + spec/mcp/spec_helpers.rb | 5 +++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 6979c439..61c79449 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -17,7 +17,7 @@ def create name: 'hackweek', version: '1.0.0', tools: TOOLS, - server_context: { user: mcp_user } + server_context: { user: mcp_user, scope: mcp_scope } ) transport_options = { stateless: true, serve_subscriptions_listen: false } # DNS-rebinding protection only allows loopback Hosts by default, which @@ -40,6 +40,13 @@ def mcp_user authed || User.new end + # Session carries full agency; tokens carry their own scope; guests have none. + def mcp_scope + return 'write' if user_signed_in? + + McpAuth.token_scope(bearer_token) + end + # Write tools need a write-scoped token; the session carries full agency. # Denied here so read-scoped callers get the same uniform denial as guests. def write_denied? diff --git a/app/mcp/create_update_tool.rb b/app/mcp/create_update_tool.rb index c864ab30..b863fa7d 100644 --- a/app/mcp/create_update_tool.rb +++ b/app/mcp/create_update_tool.rb @@ -15,6 +15,7 @@ class CreateUpdateTool < MCP::Tool def self.call(project_id:, text:, server_context:) user = user_from(server_context) return denied_response if guest?(user) + return denied_response unless server_context[:scope] == 'write' project = find_project(project_id) return denied_response if project.nil? || !member_or_originator?(project, user) diff --git a/app/mcp/join_project_tool.rb b/app/mcp/join_project_tool.rb index fbe0863b..f84f718e 100644 --- a/app/mcp/join_project_tool.rb +++ b/app/mcp/join_project_tool.rb @@ -14,6 +14,7 @@ class JoinProjectTool < MCP::Tool def self.call(project_id:, server_context:) user = user_from(server_context) return denied_response if guest?(user) + return denied_response unless server_context[:scope] == 'write' project = find_project(project_id) return denied_response if project.nil? diff --git a/spec/mcp/spec_helpers.rb b/spec/mcp/spec_helpers.rb index 9d9d5fe9..dc729f42 100644 --- a/spec/mcp/spec_helpers.rb +++ b/spec/mcp/spec_helpers.rb @@ -1,9 +1,10 @@ module McpSpecHelpers DENIED_MESSAGE = 'Not accessible through this server'.freeze - def call_tool(tool, args, user) + def call_tool(tool, args, user, scope: nil) delivered = JSON.parse(JSON.generate(args), symbolize_names: true) - tool.call(**delivered, server_context: { user: user }) + effective = scope.nil? ? (user.respond_to?(:persisted?) && user.persisted? ? 'write' : nil) : scope + tool.call(**delivered, server_context: { user: user, scope: effective }) end def response_text(response) From 175f4f6e6b5453933e1f50d6c3d24be11158674f Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:42:31 +0200 Subject: [PATCH 15/32] fix(mcp): harden join and create_update authorization --- app/mcp/create_update_tool.rb | 14 ++++++++++---- app/mcp/join_project_tool.rb | 21 +++++++++++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/mcp/create_update_tool.rb b/app/mcp/create_update_tool.rb index b863fa7d..c7b08de2 100644 --- a/app/mcp/create_update_tool.rb +++ b/app/mcp/create_update_tool.rb @@ -17,10 +17,14 @@ def self.call(project_id:, text:, server_context:) return denied_response if guest?(user) return denied_response unless server_context[:scope] == 'write' + ability = Ability.new(user) project = find_project(project_id) - return denied_response if project.nil? || !member_or_originator?(project, user) + return denied_response if project.nil? || !member_or_originator?(project, user, ability) - update = Update.create!(author: user, project: project, text: text) + cleaned = text.to_s.strip + return error_response('Update text must not be blank.') if cleaned.empty? + + update = Update.create!(author: user, project: project, text: cleaned) text_response("Created update #{update.id} on project '#{project.title}'.") rescue ActiveRecord::RecordInvalid => e error_response("Could not create update: #{e.record.errors.full_messages.to_sentence}.") @@ -41,8 +45,10 @@ def find_project(ref) Project.find_by(url: ref.to_s) || (Project.numeric?(ref) ? Project.find_by(id: ref) : nil) end - def member_or_originator?(project, user) - project.originator_id == user.id || project.users.include?(user) + def member_or_originator?(project, user, ability = nil) + return true if ability&.can?(:manage, project) + + project.originator_id == user.id || project.memberships.exists?(user_id: user.id) end def text_response(text) diff --git a/app/mcp/join_project_tool.rb b/app/mcp/join_project_tool.rb index f84f718e..fbf9aa93 100644 --- a/app/mcp/join_project_tool.rb +++ b/app/mcp/join_project_tool.rb @@ -16,14 +16,19 @@ def self.call(project_id:, server_context:) return denied_response if guest?(user) return denied_response unless server_context[:scope] == 'write' + ability = Ability.new(user) project = find_project(project_id) - return denied_response if project.nil? - return text_response("Already a member of project '#{project.title}'.") if project.users.include?(user) - - if project.join!(user) - text_response("Joined project '#{project.title}'.") - else - error_response(project.errors.full_messages.to_sentence.presence || 'Could not join project.') + return denied_response if project.nil? || !ability.can?(:read, project) || !ability.can?(:join, project) + return text_response("Already a member of project '#{project.title}'.") if project.users.exists?(user.id) + + begin + if project.join!(user) + text_response("Joined project '#{project.title}'.") + else + error_response(project.errors.full_messages.to_sentence.presence || 'Could not join project.') + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique, AASM::InvalidTransition => e + error_response("Could not join project: #{e.message}") end end @@ -39,7 +44,7 @@ def guest?(user) end def find_project(ref) - Project.find_by(url: ref.to_s) || (Project.numeric?(ref) ? Project.find_by(id: ref) : nil) + Project.find_by(url: ref.to_s) || (ref.to_s.match?(/\A\d+\z/) ? Project.find_by(id: ref) : nil) end def text_response(text) From 40a5bc54300be11f10246fa020dfaa52a896b6da Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:44:55 +0200 Subject: [PATCH 16/32] fix(mcp): filter read tools through Ability with safe pagination --- app/mcp/get_project_tool.rb | 8 ++++---- app/mcp/list_episodes_tool.rb | 15 +++++++++++++-- app/mcp/list_projects_tool.rb | 18 ++++++++++++------ app/mcp/my_projects_tool.rb | 6 ++++-- app/mcp/search_projects_tool.rb | 8 +++++--- app/models/ability.rb | 1 + spec/mcp/list_projects_tool_spec.rb | 7 +++++-- 7 files changed, 44 insertions(+), 19 deletions(-) diff --git a/app/mcp/get_project_tool.rb b/app/mcp/get_project_tool.rb index e172462e..8b6c4ac7 100644 --- a/app/mcp/get_project_tool.rb +++ b/app/mcp/get_project_tool.rb @@ -3,7 +3,7 @@ class GetProjectTool < MCP::Tool description 'Show a hackweek project with members, keywords and readable updates.' input_schema( properties: { - id: { type: 'string', description: 'Project url slug (see the url field of list_projects).' } + id: { type: %w[string integer], description: 'Project url slug (see the url field of list_projects) or id.' } }, required: ['id'] ) @@ -30,7 +30,7 @@ def user_from(server_context) def find_project(ref) scoped = Project.includes(:originator, :users, :keywords) - scoped.find_by(url: ref.to_s) || (Project.numeric?(ref) ? scoped.find_by(id: ref) : nil) + scoped.find_by(url: ref.to_s) || (ref.to_s.match?(/\A\d+\z/) ? scoped.find_by(id: ref) : nil) end def serialize(project, ability) @@ -48,8 +48,8 @@ def serialize(project, ability) end def readable_updates(project, ability) - updates = project.updates.order(created_at: :desc).limit(RECENT_UPDATE_LIMIT) - updates.select { |update| ability.can?(:read, update) }.map do |update| + updates = project.updates.includes(:author).order(created_at: :desc).limit(RECENT_UPDATE_LIMIT * 3) + updates.select { |update| ability.can?(:read, update) }.first(RECENT_UPDATE_LIMIT).map do |update| { author: update.author&.name, text: update.text } end end diff --git a/app/mcp/list_episodes_tool.rb b/app/mcp/list_episodes_tool.rb index 898c03a2..fa9eb3a0 100644 --- a/app/mcp/list_episodes_tool.rb +++ b/app/mcp/list_episodes_tool.rb @@ -4,9 +4,12 @@ class ListEpisodesTool < MCP::Tool input_schema(properties: {}, required: []) annotations(read_only_hint: true) + DENIED_MESSAGE = 'Not accessible through this server'.freeze + def self.call(server_context:) - # Public listing; the context is accepted for a uniform tool interface. - _context = server_context + ability = Ability.new(user_from(server_context)) + return denied_response unless ability.can?(:read, Episode) + entries = Episode.order(:start_date, :id).map { |episode| serialize(episode) } MCP::Tool::Response.new([{ type: 'text', text: JSON.generate(entries) }]) end @@ -14,6 +17,14 @@ def self.call(server_context:) class << self private + def user_from(server_context) + server_context[:user] + end + + def denied_response + MCP::Tool::Response.new([{ type: 'text', text: DENIED_MESSAGE }], error: true) + end + def serialize(episode) { id: episode.id, diff --git a/app/mcp/list_projects_tool.rb b/app/mcp/list_projects_tool.rb index 59882efd..11b6894d 100644 --- a/app/mcp/list_projects_tool.rb +++ b/app/mcp/list_projects_tool.rb @@ -23,9 +23,11 @@ class ListProjectsTool < MCP::Tool def self.call(server_context:, episode: nil, limit: DEFAULT_LIMIT) ability = Ability.new(user_from(server_context)) projects = scope_to_episode(Project.includes(:originator).order(:id), episode) - return denied_response if projects.nil? + return error_response('Unknown episode.') if projects.nil? - visible = projects.limit(clamp_limit(limit)).select { |project| ability.can?(:read, project) } + clamped = clamp_limit(limit) + candidates = projects.limit(clamped * 5) + visible = candidates.select { |project| ability.can?(:read, project) }.first(clamped) text_response(JSON.generate(visible.map { |project| serialize(project) })) end @@ -38,7 +40,7 @@ def user_from(server_context) def scope_to_episode(projects, episode) return scope_to_active(projects) if episode.nil? || episode.to_s.strip.empty? - return projects if episode.to_s == 'all' + return projects if episode.to_s.strip.casecmp?('all') found = Episode.find_by(id: episode) return nil if found.nil? @@ -48,12 +50,12 @@ def scope_to_episode(projects, episode) def scope_to_active(projects) active = Episode.active - active ? projects.by_episode(active) : projects + active ? projects.by_episode(active) : Project.none end def clamp_limit(limit) - clamped = limit.to_i - return DEFAULT_LIMIT if clamped <= 0 + clamped = Integer(limit, exception: false) + return DEFAULT_LIMIT if clamped.nil? || clamped <= 0 return MAX_LIMIT if clamped > MAX_LIMIT clamped @@ -74,6 +76,10 @@ def text_response(text) MCP::Tool::Response.new([{ type: 'text', text: text }]) end + def error_response(text) + MCP::Tool::Response.new([{ type: 'text', text: text }], error: true) + end + def denied_response MCP::Tool::Response.new([{ type: 'text', text: DENIED_MESSAGE }], error: true) end diff --git a/app/mcp/my_projects_tool.rb b/app/mcp/my_projects_tool.rb index be7afd7a..5493bb93 100644 --- a/app/mcp/my_projects_tool.rb +++ b/app/mcp/my_projects_tool.rb @@ -10,8 +10,10 @@ def self.call(server_context:) user = user_from(server_context) return denied_response if guest?(user) - originated = user.originated_projects.order(:id) - membered = user.projects.where.not(id: originated.select(:id)).order(:id) + ability = Ability.new(user) + originated = user.originated_projects.order(:id).limit(100).select { |project| ability.can?(:read, project) } + membered = user.projects.distinct.where.not(id: user.originated_projects.unscope(:order).select(:id)) + .order(:id).limit(100).select { |project| ability.can?(:read, project) } entries = originated.map { |project| serialize(project, 'originator') } + membered.map { |project| serialize(project, 'member') } text_response(JSON.generate(entries)) diff --git a/app/mcp/search_projects_tool.rb b/app/mcp/search_projects_tool.rb index 8ffb96e0..8febe322 100644 --- a/app/mcp/search_projects_tool.rb +++ b/app/mcp/search_projects_tool.rb @@ -15,7 +15,9 @@ class SearchProjectsTool < MCP::Tool # available in the test env; the models already maintain real-time indices. def self.call(query:, server_context:) ability = Ability.new(user_from(server_context)) - matches = search(query.to_s).select { |project| ability.can?(:read, project) } + return text_response(JSON.generate([])) unless query.is_a?(String) + + matches = search(query).select { |project| ability.can?(:read, project) }.first(RESULT_LIMIT) text_response(JSON.generate(matches.map { |project| serialize(project) })) end @@ -27,14 +29,14 @@ def user_from(server_context) end def search(query) - term = query.strip + term = query.strip.truncate(200) return Project.none if term.empty? pattern = "%#{ActiveRecord::Base.sanitize_sql_like(term.downcase)}%" conditions = ['LOWER(projects.title) LIKE :pattern', 'LOWER(projects.description) LIKE :pattern', 'LOWER(keywords.name) LIKE :pattern'].join(' OR ') - Project.left_joins(:keywords).where(conditions, pattern: pattern).distinct.order(:id).limit(RESULT_LIMIT) + Project.left_joins(:keywords).where(conditions, pattern: pattern).distinct.order(:id).limit(100) end def serialize(project) diff --git a/app/models/ability.rb b/app/models/ability.rb index 18d8e36a..2243c9ec 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -27,6 +27,7 @@ def initialize(user) can :read, User can :read, Announcement can :read, Faq + can :read, Episode can %i[join leave like dislike create], Project can %i[originated likes opportunities], User can [:enroll], Announcement diff --git a/spec/mcp/list_projects_tool_spec.rb b/spec/mcp/list_projects_tool_spec.rb index 85803083..656d139c 100644 --- a/spec/mcp/list_projects_tool_spec.rb +++ b/spec/mcp/list_projects_tool_spec.rb @@ -37,10 +37,13 @@ expect(payload.pluck('id')).to contain_exactly(elsewhere.id) end - it 'denies uniformly for an unknown episode' do + it 'returns an error for an unknown episode' do create(:idea) - expect(call_tool(described_class, { episode: 999_999 }, user)).to be_denied + response = call_tool(described_class, { episode: 999_999 }, user) + + expect(response.error?).to be(true) + expect(response).not_to be_denied end it 'caps the number of returned projects' do From d4d766d984750a3cfe0589cdc42a67ae824213bc Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:46:09 +0200 Subject: [PATCH 17/32] fix(mcp): return structured server_info payload --- app/mcp/server_info_tool.rb | 20 ++++++++++---------- spec/requests/mcp_spec.rb | 7 ++++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/mcp/server_info_tool.rb b/app/mcp/server_info_tool.rb index af8e1ca8..d04bc456 100644 --- a/app/mcp/server_info_tool.rb +++ b/app/mcp/server_info_tool.rb @@ -1,8 +1,7 @@ -# Read-only introspection for the Hackweek MCP server. Never returns secrets, -# only the calling user's own name/email plus public server facts. class ServerInfoTool < MCP::Tool tool_name 'server_info' description 'Returns basic information about the Hackweek MCP server and the calling user.' + input_schema(properties: {}, required: []) annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false) def self.call(server_context:) @@ -11,15 +10,16 @@ def self.call(server_context:) user = User.new unless user.is_a?(User) signed_in = user.persisted? - lines = ['Hackweek MCP server', "User: #{signed_in ? user.name : 'guest'}"] + payload = { + user: signed_in ? user.name : 'guest', + admin: signed_in ? user.role?('admin') : false, + organizer: signed_in ? user.role?('organizer') : false, + episode: Episode.active&.name, + tools: McpController::TOOLS.size + } # Guest responses must stay free of any email address. - lines << "Email: #{user.email}" if signed_in - lines << "Admin: #{signed_in && user.role?('admin')}" - lines << "Organizer: #{signed_in && user.role?('organizer')}" - lines << "Active episode: #{Episode.active&.name || 'none'}" - # The server owns the tool list; count tool files so later tools show up. - lines << "Tools: #{Rails.root.glob('app/mcp/*_tool.rb').count}" + payload[:email] = user.email if signed_in - MCP::Tool::Response.new([{ type: 'text', text: lines.join("\n") }]) + MCP::Tool::Response.new([{ type: 'text', text: JSON.generate(payload) }]) end end diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index c16a0cea..a7323760 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -55,8 +55,9 @@ def response_payload params: { name: 'server_info', arguments: {} } }) expect(response).to have_http_status(:ok) - expect(tool_text).to include(user.name).and include(user.email) - expect(tool_text).to include('Active episode:') + payload = response_payload + expect(payload).to include('user' => user.name, 'email' => user.email) + expect(payload).to include('admin', 'organizer', 'episode', 'tools') end it 'reports the active episode title' do @@ -64,7 +65,7 @@ def response_payload allow(Episode).to receive(:active).and_return(episode) text = ServerInfoTool.call(server_context: { user: User.new }).content.first[:text] - expect(text).to include('Hackweek 99') + expect(JSON.parse(text)).to include('episode' => 'Hackweek 99') end it 'answers guests without leaking any email' do From ed07541b6985c390f819c53edee0a86bee8d341e Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:47:23 +0200 Subject: [PATCH 18/32] fix(mcp): scope token management to requester --- app/controllers/users_controller.rb | 16 +++++++++++++--- config/routes.rb | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a4c999e5..61c1b95b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -6,6 +6,7 @@ class UsersController < ApplicationController skip_before_action :authenticate_user!, only: %i[show originated likes opportunities] skip_before_action :verify_authenticity_token, only: %i[add_keyword delete_keyword] + skip_before_action :set_updates, only: %i[tokens create_token revoke_token] def index @users = User.all.page(params[:page]).per(params[:page_size]) @@ -62,12 +63,18 @@ def delete_keyword end def tokens + @user = current_user @api_tokens = owner_tokens end def create_token - @token = ApiToken.new(user: @user, name: params[:name], scope: params[:scope]) + @user = current_user + @token = ApiToken.new(user: @user, name: params[:name], scope: params[:scope].presence || 'read') @api_tokens = owner_tokens + if @user.api_tokens.count >= 50 + @token.errors.add(:base, 'Token limit reached (maximum 50 per user)') + return render :tokens, status: :unprocessable_content + end if @token.save @raw_token = @token.raw_token render :tokens @@ -77,7 +84,10 @@ def create_token end def revoke_token - token = ApiToken.find_by!(id: params[:token_id], user_id: @user.id) + @user = current_user + token = ApiToken.find_by(id: params[:token_id], user_id: @user.id) + return redirect_back_or_to tokens_user_path(@user), alert: 'Token not found.' if token.nil? + token.revoke! redirect_back_or_to tokens_user_path(@user), notice: "Token '#{token.name}' revoked." end @@ -85,7 +95,7 @@ def revoke_token private def owner_tokens - ApiToken.where(user: @user).order(created_at: :desc) + ApiToken.where(user: @user).order(created_at: :desc).limit(100) end def user_params diff --git a/config/routes.rb b/config/routes.rb index e2292a84..d7aa6d67 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,7 +12,7 @@ delete 'keyword', to: 'users#delete_keyword', constraints: { keyword: %r{[^/]+} } get 'tokens', to: 'users#tokens' post 'tokens', to: 'users#create_token' - delete 'tokens', to: 'users#revoke_token' + delete 'tokens/:token_id', to: 'users#revoke_token' end end From 699520e0344abd9b533b54ad0f6c76ed13ff8ba5 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 20:52:04 +0200 Subject: [PATCH 19/32] fix(mcp): cover token ownership and invalid tool args --- app/controllers/users_controller.rb | 1 + spec/controllers/users_controller_spec.rb | 93 +++++++++++++++++++++++ spec/requests/mcp_spec.rb | 15 ++++ 3 files changed, 109 insertions(+) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 61c1b95b..180dc818 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -7,6 +7,7 @@ class UsersController < ApplicationController skip_before_action :authenticate_user!, only: %i[show originated likes opportunities] skip_before_action :verify_authenticity_token, only: %i[add_keyword delete_keyword] skip_before_action :set_updates, only: %i[tokens create_token revoke_token] + skip_authorize_resource only: %i[tokens create_token revoke_token] def index @users = User.all.page(params[:page]).per(params[:page_size]) diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 17dc262e..a2e71ef2 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -48,4 +48,97 @@ end.to change(user.keywords, :count).by(-1) end end + + describe 'API tokens' do + let(:other) { create(:user) } + + describe 'GET tokens' do + it 'scopes listing to the requester on cross-user paths' do + ApiToken.create!(user: other, name: 'victim') + ApiToken.create!(user: user, name: 'mine') + + get :tokens, params: { id: other.id } + + expect(assigns(:user)).to eq(user) + expect(assigns(:api_tokens).map(&:name)).to contain_exactly('mine') + end + end + + describe 'POST create_token' do + it 'mints for the requester even on cross-user paths' do + expect do + post :create_token, params: { id: other.id, name: 'x', scope: 'read' } + end.to change(user.api_tokens, :count).by(1) + + expect(other.api_tokens.count).to eq(0) + expect(ApiToken.last.user).to eq(user) + end + + it 'rejects invalid scope with 422' do + post :create_token, params: { id: user.id, name: 'x', scope: 'admin' } + + expect(response).to have_http_status(:unprocessable_content) + end + + it 'rejects blank name with 422' do + post :create_token, params: { id: user.id, name: '', scope: 'read' } + + expect(response).to have_http_status(:unprocessable_content) + end + + it 'rejects overlong name with 422' do + post :create_token, params: { id: user.id, name: 'x' * 256, scope: 'read' } + + expect(response).to have_http_status(:unprocessable_content) + end + + it 'caps minting at 50 per user' do + 50.times { |i| ApiToken.create!(user: user, name: "t#{i}") } + + expect do + post :create_token, params: { id: user.id, name: 'one-too-many', scope: 'read' } + end.not_to change(ApiToken, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end + + describe 'DELETE revoke_token' do + it 'revokes only the owner token' do + token = ApiToken.create!(user: user, name: 'mine') + + delete :revoke_token, params: { id: user.id, token_id: token.id } + + expect(token.reload.revoked?).to be(true) + end + + it 'does not revoke another user token' do + victim_token = ApiToken.create!(user: other, name: 'victim') + + delete :revoke_token, params: { id: other.id, token_id: victim_token.id } + + expect(victim_token.reload.revoked?).to be(false) + expect(response).to redirect_to(tokens_user_path(user)) + expect(flash[:alert]).to be_present + end + + it 'redirects with alert when already gone' do + delete :revoke_token, params: { id: user.id, token_id: 999_999 } + + expect(response).to redirect_to(tokens_user_path(user)) + expect(flash[:alert]).to be_present + end + + it 'forbids cross-user revoke even for admins' do + admin = create(:admin) + sign_in admin + victim_token = ApiToken.create!(user: other, name: 'victim') + + delete :revoke_token, params: { id: other.id, token_id: victim_token.id } + + expect(victim_token.reload.revoked?).to be(false) + expect(ApiToken.where(user: admin).count).to eq(0) + end + end + end end diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index a7323760..3fbde3ff 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -269,4 +269,19 @@ def expect_no_token_leak(raw) expect(File.read(log_path, mode: 'r:UTF-8')).not_to include(write_raw) if File.exist?(log_path) end end + + it 'returns a JSON-RPC error for missing id, not 500' do + mcp_post({ jsonrpc: '2.0', id: 99, method: 'tools/call', + params: { name: 'get_project', arguments: {} } }) + + expect(response).to have_http_status(:ok) + expect(result_body['isError']).to be(true) + end + + it 'returns a JSON-RPC error for array params, not 500' do + mcp_post({ jsonrpc: '2.0', id: 100, method: 'tools/call', params: [] }) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['error']).to be_present + end end From fabe0c4184ebb04bbac36d4c48f48c33067ecfc7 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 21:08:06 +0200 Subject: [PATCH 20/32] fix(mcp): satisfy rubocop on changed files --- app/controllers/users_controller.rb | 2 +- app/mcp/join_project_tool.rb | 6 +++++- spec/mcp/spec_helpers.rb | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 180dc818..a0edc406 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -87,7 +87,7 @@ def create_token def revoke_token @user = current_user token = ApiToken.find_by(id: params[:token_id], user_id: @user.id) - return redirect_back_or_to tokens_user_path(@user), alert: 'Token not found.' if token.nil? + return redirect_back_or_to tokens_user_path(@user), alert: 'Token not found.' if token.nil? # rubocop:disable Rails/I18nLocaleTexts token.revoke! redirect_back_or_to tokens_user_path(@user), notice: "Token '#{token.name}' revoked." diff --git a/app/mcp/join_project_tool.rb b/app/mcp/join_project_tool.rb index fbf9aa93..b479c9a2 100644 --- a/app/mcp/join_project_tool.rb +++ b/app/mcp/join_project_tool.rb @@ -18,7 +18,7 @@ def self.call(project_id:, server_context:) ability = Ability.new(user) project = find_project(project_id) - return denied_response if project.nil? || !ability.can?(:read, project) || !ability.can?(:join, project) + return denied_response unless joinable?(ability, project) return text_response("Already a member of project '#{project.title}'.") if project.users.exists?(user.id) begin @@ -35,6 +35,10 @@ def self.call(project_id:, server_context:) class << self private + def joinable?(ability, project) + !project.nil? && ability.can?(:read, project) && ability.can?(:join, project) + end + def user_from(server_context) server_context[:user] # Hash and MCP::ServerContext both answer #[] end diff --git a/spec/mcp/spec_helpers.rb b/spec/mcp/spec_helpers.rb index dc729f42..8a199971 100644 --- a/spec/mcp/spec_helpers.rb +++ b/spec/mcp/spec_helpers.rb @@ -3,7 +3,8 @@ module McpSpecHelpers def call_tool(tool, args, user, scope: nil) delivered = JSON.parse(JSON.generate(args), symbolize_names: true) - effective = scope.nil? ? (user.respond_to?(:persisted?) && user.persisted? ? 'write' : nil) : scope + effective = scope + effective = 'write' if effective.nil? && user.respond_to?(:persisted?) && user.persisted? tool.call(**delivered, server_context: { user: user, scope: effective }) end From d209f955725f43ed3ea213718f15a740c05cc402 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Mon, 12 Oct 2026 21:14:53 +0200 Subject: [PATCH 21/32] fix(mcp): use strict id check in create_update, cover float-like ids --- app/mcp/create_update_tool.rb | 2 +- spec/mcp/create_update_tool_spec.rb | 4 ++++ spec/mcp/get_project_tool_spec.rb | 4 ++++ spec/mcp/join_project_tool_spec.rb | 4 ++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/mcp/create_update_tool.rb b/app/mcp/create_update_tool.rb index c7b08de2..2cbfb5c4 100644 --- a/app/mcp/create_update_tool.rb +++ b/app/mcp/create_update_tool.rb @@ -42,7 +42,7 @@ def guest?(user) end def find_project(ref) - Project.find_by(url: ref.to_s) || (Project.numeric?(ref) ? Project.find_by(id: ref) : nil) + Project.find_by(url: ref.to_s) || (ref.to_s.match?(/\A\d+\z/) ? Project.find_by(id: ref) : nil) end def member_or_originator?(project, user, ability = nil) diff --git a/spec/mcp/create_update_tool_spec.rb b/spec/mcp/create_update_tool_spec.rb index 9dacbc9b..4785c471 100644 --- a/spec/mcp/create_update_tool_spec.rb +++ b/spec/mcp/create_update_tool_spec.rb @@ -39,6 +39,10 @@ expect(call_tool(described_class, { project_id: 999_999, text: 'hi' }, user)).to be_denied end + it 'denies float-like ids instead of truncating' do + expect(call_tool(described_class, { project_id: '1.5', text: 'hi' }, user)).to be_denied + end + it 'returns an error response on validation failure' do project_id = project.id invalid = Update.new diff --git a/spec/mcp/get_project_tool_spec.rb b/spec/mcp/get_project_tool_spec.rb index 820a0c5f..68397cdc 100644 --- a/spec/mcp/get_project_tool_spec.rb +++ b/spec/mcp/get_project_tool_spec.rb @@ -27,6 +27,10 @@ expect(call_tool(described_class, { id: 'no-such-project' }, user)).to be_denied end + it 'denies float-like ids instead of truncating' do + expect(call_tool(described_class, { id: '1.5' }, user)).to be_denied + end + it 'hides updates authored by other users' do other = create(:user) Update.create!(author: other, project: project, text: 'theirs') diff --git a/spec/mcp/join_project_tool_spec.rb b/spec/mcp/join_project_tool_spec.rb index 3ee4d98e..d5ef207d 100644 --- a/spec/mcp/join_project_tool_spec.rb +++ b/spec/mcp/join_project_tool_spec.rb @@ -32,6 +32,10 @@ expect(call_tool(described_class, { project_id: 999_999 }, user)).to be_denied end + it 'denies float-like ids instead of truncating' do + expect(call_tool(described_class, { project_id: '1.5' }, user)).to be_denied + end + it 'returns an error response for finished projects' do finished = create(:invention) From 39c01f3d8080bfd96d546110dd53ef73b54fc1fd Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 10:48:09 +0200 Subject: [PATCH 22/32] fix(mcp): trust request host in transport allowlist in all envs --- app/controllers/mcp_controller.rb | 7 ++++--- spec/requests/mcp_spec.rb | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 61c79449..639ea4d4 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -20,9 +20,10 @@ def create server_context: { user: mcp_user, scope: mcp_scope } ) transport_options = { stateless: true, serve_subscriptions_listen: false } - # DNS-rebinding protection only allows loopback Hosts by default, which - # rejects the test suite's dummy host; widen it in test env only. - transport_options[:allowed_hosts] = [request.host] if Rails.env.test? + # DNS-rebinding protection only allows loopback Hosts by default; Rails' + # own HostAuthorization already rejected unknown hosts before this + # controller, so trusting the surviving request host is safe in all envs. + transport_options[:allowed_hosts] = [request.host] transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, **transport_options) status, headers, body = transport.handle_request(request) diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index 3fbde3ff..6cac1387 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -40,6 +40,20 @@ def response_payload expect(result_body['serverInfo']['name']).to eq('hackweek') end + it 'trusts the request host outside the test env' do + Rails.application.config.hosts << 'hackweek.opensuse.org' + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) + host! 'hackweek.opensuse.org' + mcp_post({ jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, + clientInfo: { name: 'spec', version: '0.1' } } }) + + expect(response).to have_http_status(:ok) + expect(result_body['serverInfo']['name']).to eq('hackweek') + ensure + Rails.application.config.hosts.delete('hackweek.opensuse.org') + end + it 'lists tools including server_info' do mcp_post({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) From 8576f9836c555d089b158494ed5db638cb1c134d Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 10:52:45 +0200 Subject: [PATCH 23/32] fix(mcp): authenticate bearer once, ignore session, gate CSRF skip on real tokens --- app/controllers/mcp_controller.rb | 34 +++++++++++++----- app/mcp/mcp_auth.rb | 16 ++++++--- spec/requests/mcp_spec.rb | 60 +++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 639ea4d4..e2855454 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -2,7 +2,7 @@ class McpController < ApplicationController skip_before_action :store_location, :load_news, :set_episode, :load_notifications skip_before_action :authenticate_user! - skip_forgery_protection only: [:create], if: -> { bearer_token.present? } + skip_forgery_protection only: [:create], if: -> { mcp_bearer_authenticated? } TOOLS = [ServerInfoTool, ListProjectsTool, GetProjectTool, SearchProjectsTool, ListEpisodesTool, MyProjectsTool, CreateUpdateTool, JoinProjectTool].freeze @@ -33,19 +33,22 @@ def create private # Devise session or Bearer token, falling back to a guest (unpersisted) user. + # A present Bearer header owns the request: the session is ignored, so a + # junk secret degrades to guest even with a valid cookie. def mcp_user - return current_user if user_signed_in? + return mcp_auth_token.user if mcp_auth_token + return current_user if bearer_token.nil? && user_signed_in? - token = bearer_token - authed = McpAuth.authenticate_bearer(token) if token - authed || User.new + User.new end - # Session carries full agency; tokens carry their own scope; guests have none. + # Tokens carry their own scope; the session carries full agency; guests none. + # Mirrors mcp_user so both derive from the single memoized authentication. def mcp_scope - return 'write' if user_signed_in? + return mcp_auth_token.scope if mcp_auth_token + return 'write' if bearer_token.nil? && user_signed_in? - McpAuth.token_scope(bearer_token) + nil end # Write tools need a write-scoped token; the session carries full agency. @@ -73,4 +76,19 @@ def bearer_token match = request.headers['Authorization'].to_s.match(/\ABearer\s+(.+)\z/i) match && match[1] end + + # Skips forgery protection only for requests carrying a real token; a + # junk Bearer still faces the session CSRF check. + def mcp_bearer_authenticated? + mcp_auth_token.present? + end + + # Single memoized authentication per request (nil-safe: a junk secret + # authenticates once, then stays nil for the user, scope and write checks). + def mcp_auth_token + return @mcp_auth_token if defined?(@mcp_auth_token) + + raw = bearer_token + @mcp_auth_token = raw ? McpAuth.authenticate_token(raw) : nil + end end diff --git a/app/mcp/mcp_auth.rb b/app/mcp/mcp_auth.rb index 04e620bc..00e9a48c 100644 --- a/app/mcp/mcp_auth.rb +++ b/app/mcp/mcp_auth.rb @@ -1,16 +1,22 @@ # Bearer-token authentication for the MCP endpoint. module McpAuth - # Returns the token-owning user, nil for missing/unknown/revoked secrets. - def self.authenticate_bearer(token) - api_token = ApiToken.authenticate(token.to_s.strip) + # Returns the live token record for a presented secret, nil when + # missing/unknown/revoked. + def self.authenticate_token(raw) + api_token = ApiToken.authenticate(raw.to_s.strip) return nil if api_token.nil? api_token.update!(last_used_at: Time.current) - api_token.user + api_token + end + + # Returns the token-owning user, nil for missing/unknown/revoked secrets. + def self.authenticate_bearer(token) + authenticate_token(token)&.user end # Effective scope of the presented secret, nil when unauthenticated. def self.token_scope(token) - ApiToken.authenticate(token.to_s.strip)&.scope + authenticate_token(token)&.scope end end diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index 6cac1387..dd50d175 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -115,6 +115,66 @@ def expect_no_token_leak(raw) expect(response.body).not_to include(ApiToken.digest(raw)) end + it 'authenticates the bearer only once per request' do + expect(ApiToken).to receive(:authenticate).once.and_call_original + + tool_call('server_info', {}, read_raw) + + expect(response).to have_http_status(:ok) + end + + it 'ignores the session when a bearer is present, treating junk as guest' do + other = create(:user) + sign_in other + mcp_post({ jsonrpc: '2.0', id: 101, method: 'tools/call', + params: { name: 'server_info', arguments: {} } }, + bearer_headers('junk-secret')) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include('guest') + expect(tool_text).not_to include(other.email) + end + + it 'acts as the token owner when session and bearer disagree' do + other = create(:user) + sign_in other + + tool_call('server_info', {}, read_raw) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include(user.email) + expect(tool_text).not_to include(other.email) + end + + context 'with forgery protection enabled' do + around do |example| + previous = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + example.run + ActionController::Base.allow_forgery_protection = previous + end + + it 'skips forgery protection for a real bearer' do + tool_call('server_info', {}, read_raw) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include(user.email) + end + + it 'nulls the session for a junk bearer, denying as guest' do + other = create(:user) + sign_in other + payload = { jsonrpc: '2.0', id: 103, method: 'tools/call', + params: { name: 'server_info', arguments: {} } } + + mcp_post(payload, bearer_headers('junk-secret')) + + expect(response).to have_http_status(:ok) + expect(tool_text).to include('guest') + expect(tool_text).not_to include(other.email) + end + end + it 'lists all eight wired tools' do mcp_post({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }, bearer_headers(read_raw)) From 688e5d0d8dc74d47de6343c71ab1f42686c7ec89 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 10:54:13 +0200 Subject: [PATCH 24/32] fix(mcp): throttle last_used_at writes to one per minute --- app/mcp/mcp_auth.rb | 15 ++++++++++++++- spec/requests/mcp_spec.rb | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/mcp/mcp_auth.rb b/app/mcp/mcp_auth.rb index 00e9a48c..f2dc59ee 100644 --- a/app/mcp/mcp_auth.rb +++ b/app/mcp/mcp_auth.rb @@ -1,12 +1,16 @@ # Bearer-token authentication for the MCP endpoint. module McpAuth + # Minimum gap between last_used_at writes; hot clients must not dirty a + # row on every call. + TOUCH_INTERVAL = 1.minute + # Returns the live token record for a presented secret, nil when # missing/unknown/revoked. def self.authenticate_token(raw) api_token = ApiToken.authenticate(raw.to_s.strip) return nil if api_token.nil? - api_token.update!(last_used_at: Time.current) + touch_last_used(api_token) api_token end @@ -19,4 +23,13 @@ def self.authenticate_bearer(token) def self.token_scope(token) authenticate_token(token)&.scope end + + # Refresh last_used_at at most once per TOUCH_INTERVAL, bypassing + # validations, callbacks and updated_at churn. + def self.touch_last_used(api_token) + stamp = api_token.last_used_at + return if stamp.present? && stamp > TOUCH_INTERVAL.ago + + api_token.update_column(:last_used_at, Time.current) + end end diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index dd50d175..79c06f2a 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -175,6 +175,31 @@ def expect_no_token_leak(raw) end end + it 'throttles last_used_at writes to one per minute' do + tool_call('server_info', {}, read_raw) + expect(read_token.reload.last_used_at).to be_present + + # A call within the throttle window leaves last_used_at and + # updated_at alone. + read_token.update_column(:last_used_at, 30.seconds.ago) + read_token.update_column(:updated_at, 1.hour.ago) + fresh_stamp = read_token.reload.last_used_at + old_updated = read_token.updated_at + + tool_call('server_info', {}, read_raw) + + expect(response).to have_http_status(:ok) + expect(read_token.reload.last_used_at).to eq(fresh_stamp) + expect(read_token.reload.updated_at).to eq(old_updated) + + # A stale stamp refreshes. + read_token.update_column(:last_used_at, 2.minutes.ago) + + tool_call('server_info', {}, read_raw) + + expect(read_token.reload.last_used_at).to be > fresh_stamp + end + it 'lists all eight wired tools' do mcp_post({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: {} }, bearer_headers(read_raw)) From f88bd7165bb66fd401b959472fceacbf8c2f0e98 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 10:54:59 +0200 Subject: [PATCH 25/32] fix(mcp): ack JSON-RPC notifications with a bare 202 --- app/controllers/mcp_controller.rb | 4 ++++ spec/requests/mcp_spec.rb | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index e2855454..83350f3f 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -27,6 +27,10 @@ def create transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, **transport_options) status, headers, body = transport.handle_request(request) + # JSON-RPC notifications ack with 202 and no body; rendering + # body.first (nil) would answer `null` instead. + return head status if body.empty? + render(json: body.first, status:, headers:) end diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index 79c06f2a..379486d6 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -92,6 +92,13 @@ def response_payload expect(tool_text).not_to include('@') end + it 'acks JSON-RPC notifications with a bare 202' do + mcp_post({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) + + expect(response).to have_http_status(:accepted) + expect(response.body).to be_empty + end + it 'rejects non-JSON posts with 415' do post '/mcp', params: 'hello', headers: { 'CONTENT_TYPE' => 'text/plain' } From 5235cab6511407870e0aeed62650565be1d210fd Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 11:00:32 +0200 Subject: [PATCH 26/32] fix(mcp): restrict create_update to originators and members --- app/mcp/create_update_tool.rb | 9 ++++----- spec/mcp/create_update_tool_spec.rb | 11 +++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/mcp/create_update_tool.rb b/app/mcp/create_update_tool.rb index 2cbfb5c4..6a5b6db9 100644 --- a/app/mcp/create_update_tool.rb +++ b/app/mcp/create_update_tool.rb @@ -17,9 +17,8 @@ def self.call(project_id:, text:, server_context:) return denied_response if guest?(user) return denied_response unless server_context[:scope] == 'write' - ability = Ability.new(user) project = find_project(project_id) - return denied_response if project.nil? || !member_or_originator?(project, user, ability) + return denied_response if project.nil? || !member_or_originator?(project, user) cleaned = text.to_s.strip return error_response('Update text must not be blank.') if cleaned.empty? @@ -45,9 +44,9 @@ def find_project(ref) Project.find_by(url: ref.to_s) || (ref.to_s.match?(/\A\d+\z/) ? Project.find_by(id: ref) : nil) end - def member_or_originator?(project, user, ability = nil) - return true if ability&.can?(:manage, project) - + # Only originators and members may post; admins join first like + # everyone else. + def member_or_originator?(project, user) project.originator_id == user.id || project.memberships.exists?(user_id: user.id) end diff --git a/spec/mcp/create_update_tool_spec.rb b/spec/mcp/create_update_tool_spec.rb index 4785c471..3b9a6590 100644 --- a/spec/mcp/create_update_tool_spec.rb +++ b/spec/mcp/create_update_tool_spec.rb @@ -20,6 +20,17 @@ expect(response.error?).to be(false) end + it 'denies admins who neither originate nor belong' do + admin = create(:admin) + denied_id = project.id + response = nil + expect do + response = call_tool(described_class, { project_id: denied_id, text: 'hijack' }, admin) + end.not_to change(Update, :count) + + expect(response).to be_denied + end + it 'denies uniformly for other users' do stranger = create(:user) denied_id = project.id From 8423a92da6ddee6f46abd1282bbf4e927a1ca550 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 11:01:07 +0200 Subject: [PATCH 27/32] fix(mcp): simplify list_projects filtering to SQL limit plus guard --- app/mcp/list_projects_tool.rb | 5 +++-- spec/mcp/list_projects_tool_spec.rb | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/mcp/list_projects_tool.rb b/app/mcp/list_projects_tool.rb index 11b6894d..e062f03a 100644 --- a/app/mcp/list_projects_tool.rb +++ b/app/mcp/list_projects_tool.rb @@ -26,8 +26,9 @@ def self.call(server_context:, episode: nil, limit: DEFAULT_LIMIT) return error_response('Unknown episode.') if projects.nil? clamped = clamp_limit(limit) - candidates = projects.limit(clamped * 5) - visible = candidates.select { |project| ability.can?(:read, project) }.first(clamped) + # Reads are currently unconditional, so SQL-limit to the asked size; the + # Ruby select stays as a guard so future per-project rules filter here. + visible = projects.limit(clamped).select { |project| ability.can?(:read, project) } text_response(JSON.generate(visible.map { |project| serialize(project) })) end diff --git a/spec/mcp/list_projects_tool_spec.rb b/spec/mcp/list_projects_tool_spec.rb index 656d139c..2b68472b 100644 --- a/spec/mcp/list_projects_tool_spec.rb +++ b/spec/mcp/list_projects_tool_spec.rb @@ -46,6 +46,19 @@ expect(response).not_to be_denied end + it 'filters through the ability guard when rules deny a project' do + first = create(:idea) + second = create(:idea) + guarded = Ability.new(user) + allow(guarded).to receive(:can?).and_call_original + allow(guarded).to receive(:can?).with(:read, second).and_return(false) + allow(Ability).to receive(:new).and_return(guarded) + + payload = response_payload(call_tool(described_class, { episode: 'all' }, user)) + + expect(payload.pluck('id')).to contain_exactly(first.id) + end + it 'caps the number of returned projects' do create_list(:idea, 3) From e452854682ff57bc578e0e10be3662cbe6f205cd Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 11:02:04 +0200 Subject: [PATCH 28/32] fix(mcp): simplify search filtering to SQL limit plus guard --- app/mcp/search_projects_tool.rb | 8 ++++++-- spec/mcp/search_projects_tool_spec.rb | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/mcp/search_projects_tool.rb b/app/mcp/search_projects_tool.rb index 8febe322..64e394b4 100644 --- a/app/mcp/search_projects_tool.rb +++ b/app/mcp/search_projects_tool.rb @@ -17,7 +17,9 @@ def self.call(query:, server_context:) ability = Ability.new(user_from(server_context)) return text_response(JSON.generate([])) unless query.is_a?(String) - matches = search(query).select { |project| ability.can?(:read, project) }.first(RESULT_LIMIT) + # Reads are currently unconditional, so SQL-limit to the result size; the + # Ruby select stays as a guard so future per-project rules filter here. + matches = search(query).limit(RESULT_LIMIT).select { |project| ability.can?(:read, project) } text_response(JSON.generate(matches.map { |project| serialize(project) })) end @@ -32,11 +34,13 @@ def search(query) term = query.strip.truncate(200) return Project.none if term.empty? + # LIKE search capped at 200 chars; the site search is equally public, + # so no extra throttling here. pattern = "%#{ActiveRecord::Base.sanitize_sql_like(term.downcase)}%" conditions = ['LOWER(projects.title) LIKE :pattern', 'LOWER(projects.description) LIKE :pattern', 'LOWER(keywords.name) LIKE :pattern'].join(' OR ') - Project.left_joins(:keywords).where(conditions, pattern: pattern).distinct.order(:id).limit(100) + Project.left_joins(:keywords).where(conditions, pattern: pattern).distinct.order(:id) end def serialize(project) diff --git a/spec/mcp/search_projects_tool_spec.rb b/spec/mcp/search_projects_tool_spec.rb index 14f9832b..a7a81f16 100644 --- a/spec/mcp/search_projects_tool_spec.rb +++ b/spec/mcp/search_projects_tool_spec.rb @@ -42,4 +42,18 @@ expect(response.error?).to be(false) expect(response_payload(response).length).to eq(3) end + + it 'filters through the ability guard when rules deny a project' do + denied_title = "Denied #{rand(1_000_000)}" + kept = create(:idea, title: denied_title) + allowed = create(:idea, title: "#{denied_title} allowed") + guarded = Ability.new(user) + allow(guarded).to receive(:can?).and_call_original + allow(guarded).to receive(:can?).with(:read, allowed).and_return(false) + allow(Ability).to receive(:new).and_return(guarded) + + payload = response_payload(call_tool(described_class, { query: denied_title }, user)) + + expect(payload.pluck('id')).to contain_exactly(kept.id) + end end From 14def22da0a60cb7e70da5152720668ed53fc560 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 11:02:55 +0200 Subject: [PATCH 29/32] fix(mcp): drop controller write pre-check, tools deny uniformly --- app/controllers/mcp_controller.rb | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 83350f3f..f28932e1 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -6,12 +6,9 @@ class McpController < ApplicationController TOOLS = [ServerInfoTool, ListProjectsTool, GetProjectTool, SearchProjectsTool, ListEpisodesTool, MyProjectsTool, CreateUpdateTool, JoinProjectTool].freeze - WRITE_TOOLS = %w[create_update join_project].freeze - DENIED_MESSAGE = 'Not accessible through this server'.freeze def create return head :unsupported_media_type unless request.media_type == 'application/json' - return render_write_denied if write_denied? server = MCP::Server.new( name: 'hackweek', @@ -55,27 +52,8 @@ def mcp_scope nil end - # Write tools need a write-scoped token; the session carries full agency. - # Denied here so read-scoped callers get the same uniform denial as guests. - def write_denied? - return false if user_signed_in? - return false unless params[:method] == 'tools/call' - - inner = params[:params] - return false unless inner.is_a?(Hash) || inner.is_a?(ActionController::Parameters) - return false unless WRITE_TOOLS.include?(inner[:name]) - - McpAuth.token_scope(bearer_token) != 'write' - end - - def render_write_denied - render json: { - jsonrpc: '2.0', - id: params[:id], - result: { content: [{ type: 'text', text: DENIED_MESSAGE }], isError: true } - } - end - + # Write tools enforce the write scope themselves with the same uniform + # denial, so no controller pre-check is needed here. def bearer_token match = request.headers['Authorization'].to_s.match(/\ABearer\s+(.+)\z/i) match && match[1] From 8ab3c375c21dcb758d2920cc29429729a7bc525d Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 11:03:39 +0200 Subject: [PATCH 30/32] fix(mcp): show token settings only to the profile owner --- app/views/users/edit.html.haml | 2 +- spec/views/users/edit.html.haml_spec.rb | 28 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 spec/views/users/edit.html.haml_spec.rb diff --git a/app/views/users/edit.html.haml b/app/views/users/edit.html.haml index 787c7710..e52a7041 100644 --- a/app/views/users/edit.html.haml +++ b/app/views/users/edit.html.haml @@ -31,7 +31,7 @@ .col-sm-offset-2.col-sm-10 = f.submit :class => "btn btn-default" -- if can? :tokens, @user +- if @user == current_user %hr .row .col-md-12 diff --git a/spec/views/users/edit.html.haml_spec.rb b/spec/views/users/edit.html.haml_spec.rb new file mode 100644 index 00000000..3f829ec7 --- /dev/null +++ b/spec/views/users/edit.html.haml_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +describe 'users/edit.html.haml' do + include Devise::Test::ControllerHelpers + + let(:owner) { create(:user) } + + before do + sign_in owner + assign(:api_tokens, []) + end + + it 'shows the tokens section on your own profile' do + assign(:user, owner) + + render + + expect(rendered).to include('API tokens') + end + + it 'hides the tokens section on another profile' do + assign(:user, create(:user)) + + render + + expect(rendered).not_to include('API tokens') + end +end From 3227a803accc324737d4a4b242aa16d66d6bef80 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 11:05:44 +0200 Subject: [PATCH 31/32] fix(mcp): serve minted token secret with Cache-Control no-store --- app/controllers/users_controller.rb | 2 ++ spec/controllers/users_controller_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a0edc406..54136ef1 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -78,6 +78,8 @@ def create_token end if @token.save @raw_token = @token.raw_token + # The raw secret renders once; never let caches store it. + response.headers['Cache-Control'] = 'no-store' render :tokens else render :tokens, status: :unprocessable_content diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index a2e71ef2..9dec4041 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -74,6 +74,13 @@ expect(ApiToken.last.user).to eq(user) end + it 'serves the minted secret with Cache-Control no-store' do + post :create_token, params: { id: user.id, name: 'x', scope: 'read' } + + expect(response.headers['Cache-Control']).to eq('no-store') + expect(assigns(:raw_token)).to be_present + end + it 'rejects invalid scope with 422' do post :create_token, params: { id: user.id, name: 'x', scope: 'admin' } From 6aaa7b6cea4427630bfcf227f2cacc4ad7c36de9 Mon Sep 17 00:00:00 2001 From: Martin Pluskal Date: Tue, 13 Oct 2026 11:11:26 +0200 Subject: [PATCH 32/32] fix(mcp): satisfy rubocop on changed files --- app/mcp/mcp_auth.rb | 2 +- spec/requests/mcp_spec.rb | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/mcp/mcp_auth.rb b/app/mcp/mcp_auth.rb index f2dc59ee..c9303900 100644 --- a/app/mcp/mcp_auth.rb +++ b/app/mcp/mcp_auth.rb @@ -30,6 +30,6 @@ def self.touch_last_used(api_token) stamp = api_token.last_used_at return if stamp.present? && stamp > TOUCH_INTERVAL.ago - api_token.update_column(:last_used_at, Time.current) + api_token.update_column(:last_used_at, Time.current) # rubocop:disable Rails/SkipsModelValidations end end diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb index 379486d6..6c89ee34 100644 --- a/spec/requests/mcp_spec.rb +++ b/spec/requests/mcp_spec.rb @@ -123,11 +123,12 @@ def expect_no_token_leak(raw) end it 'authenticates the bearer only once per request' do - expect(ApiToken).to receive(:authenticate).once.and_call_original + allow(ApiToken).to receive(:authenticate).and_call_original tool_call('server_info', {}, read_raw) expect(response).to have_http_status(:ok) + expect(ApiToken).to have_received(:authenticate).once end it 'ignores the session when a bearer is present, treating junk as guest' do @@ -182,14 +183,10 @@ def expect_no_token_leak(raw) end end - it 'throttles last_used_at writes to one per minute' do + it 'leaves last_used_at alone within a minute' do tool_call('server_info', {}, read_raw) - expect(read_token.reload.last_used_at).to be_present - - # A call within the throttle window leaves last_used_at and - # updated_at alone. - read_token.update_column(:last_used_at, 30.seconds.ago) - read_token.update_column(:updated_at, 1.hour.ago) + read_token.update_column(:last_used_at, 30.seconds.ago) # rubocop:disable Rails/SkipsModelValidations + read_token.update_column(:updated_at, 1.hour.ago) # rubocop:disable Rails/SkipsModelValidations fresh_stamp = read_token.reload.last_used_at old_updated = read_token.updated_at @@ -198,12 +195,16 @@ def expect_no_token_leak(raw) expect(response).to have_http_status(:ok) expect(read_token.reload.last_used_at).to eq(fresh_stamp) expect(read_token.reload.updated_at).to eq(old_updated) + end - # A stale stamp refreshes. - read_token.update_column(:last_used_at, 2.minutes.ago) + it 'refreshes a stale last_used_at' do + tool_call('server_info', {}, read_raw) + fresh_stamp = read_token.reload.last_used_at + read_token.update_column(:last_used_at, 2.minutes.ago) # rubocop:disable Rails/SkipsModelValidations tool_call('server_info', {}, read_raw) + expect(response).to have_http_status(:ok) expect(read_token.reload.last_used_at).to be > fresh_stamp end