diff --git a/Gemfile b/Gemfile index 5a3e118aa..100e4914e 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 e87c303f2..13c72d447 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 diff --git a/README.md b/README.md index 3cc3c7c70..470ab110a 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. diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 000000000..f28932e18 --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,76 @@ +# 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], if: -> { mcp_bearer_authenticated? } + + TOOLS = [ServerInfoTool, ListProjectsTool, GetProjectTool, SearchProjectsTool, + ListEpisodesTool, MyProjectsTool, CreateUpdateTool, JoinProjectTool].freeze + + def create + return head :unsupported_media_type unless request.media_type == 'application/json' + + server = MCP::Server.new( + name: 'hackweek', + version: '1.0.0', + tools: TOOLS, + 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; 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) + + # 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 + + 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 mcp_auth_token.user if mcp_auth_token + return current_user if bearer_token.nil? && user_signed_in? + + User.new + end + + # 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 mcp_auth_token.scope if mcp_auth_token + return 'write' if bearer_token.nil? && user_signed_in? + + nil + 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] + 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/controllers/users_controller.rb b/app/controllers/users_controller.rb index d87ae898f..54136ef1b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -6,6 +6,8 @@ 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]) @@ -15,7 +17,9 @@ def index end end - def edit; end + def edit + @api_tokens = owner_tokens + end def update @user.update(params['user']) @@ -59,8 +63,44 @@ def delete_keyword redirect_to @user, notice: "Keyword '#{params[:keyword]}' removed." end + def tokens + @user = current_user + @api_tokens = owner_tokens + end + + def create_token + @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 + # The raw secret renders once; never let caches store it. + response.headers['Cache-Control'] = 'no-store' + render :tokens + else + render :tokens, status: :unprocessable_content + end + end + + 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? # rubocop:disable Rails/I18nLocaleTexts + + 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).limit(100) + end + def user_params params.require(:user).permit(:location, :hide_email) end diff --git a/app/mcp/create_update_tool.rb b/app/mcp/create_update_tool.rb new file mode 100644 index 000000000..6a5b6db97 --- /dev/null +++ b/app/mcp/create_update_tool.rb @@ -0,0 +1,65 @@ +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) + return denied_response unless server_context[:scope] == 'write' + + project = find_project(project_id) + 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? + + 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}.") + end + + class << self + private + + def user_from(server_context) + server_context[:user] # Hash and MCP::ServerContext both answer #[] + end + + def guest?(user) + user.nil? || (user.respond_to?(:persisted?) && !user.persisted?) + end + + 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 + + # 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 + + 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/get_project_tool.rb b/app/mcp/get_project_tool.rb new file mode 100644 index 000000000..8b6c4ac70 --- /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: %w[string integer], description: 'Project url slug (see the url field of list_projects) or id.' } + }, + 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[:user] # Hash and MCP::ServerContext both answer #[] + end + + def find_project(ref) + scoped = Project.includes(:originator, :users, :keywords) + 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) + { + 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.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 + + 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/join_project_tool.rb b/app/mcp/join_project_tool.rb new file mode 100644 index 000000000..b479c9a28 --- /dev/null +++ b/app/mcp/join_project_tool.rb @@ -0,0 +1,66 @@ +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) + return denied_response unless server_context[:scope] == 'write' + + ability = Ability.new(user) + project = find_project(project_id) + return denied_response unless joinable?(ability, 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 + + 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 + + def guest?(user) + user.nil? || (user.respond_to?(:persisted?) && !user.persisted?) + end + + 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 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/list_episodes_tool.rb b/app/mcp/list_episodes_tool.rb new file mode 100644 index 000000000..fa9eb3a04 --- /dev/null +++ b/app/mcp/list_episodes_tool.rb @@ -0,0 +1,38 @@ +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) + + DENIED_MESSAGE = 'Not accessible through this server'.freeze + + def self.call(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 + + 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, + 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 000000000..e062f03ae --- /dev/null +++ b/app/mcp/list_projects_tool.rb @@ -0,0 +1,88 @@ +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 error_response('Unknown episode.') if projects.nil? + + clamped = clamp_limit(limit) + # 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 + + class << self + private + + def user_from(server_context) + server_context[:user] # Hash and MCP::ServerContext both answer #[] + 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.strip.casecmp?('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) : Project.none + end + + def clamp_limit(limit) + clamped = Integer(limit, exception: false) + return DEFAULT_LIMIT if clamped.nil? || 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 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/mcp_auth.rb b/app/mcp/mcp_auth.rb new file mode 100644 index 000000000..c93039001 --- /dev/null +++ b/app/mcp/mcp_auth.rb @@ -0,0 +1,35 @@ +# 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? + + touch_last_used(api_token) + 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) + 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) # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/app/mcp/my_projects_tool.rb b/app/mcp/my_projects_tool.rb new file mode 100644 index 000000000..5493bb93b --- /dev/null +++ b/app/mcp/my_projects_tool.rb @@ -0,0 +1,45 @@ +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) + + 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)) + end + + class << self + private + + def user_from(server_context) + server_context[:user] # Hash and MCP::ServerContext both answer #[] + 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/app/mcp/search_projects_tool.rb b/app/mcp/search_projects_tool.rb new file mode 100644 index 000000000..64e394b49 --- /dev/null +++ b/app/mcp/search_projects_tool.rb @@ -0,0 +1,54 @@ +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)) + return text_response(JSON.generate([])) unless query.is_a?(String) + + # 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 + + class << self + private + + def user_from(server_context) + server_context[:user] # Hash and MCP::ServerContext both answer #[] + end + + 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) + 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/app/mcp/server_info_tool.rb b/app/mcp/server_info_tool.rb new file mode 100644 index 000000000..d04bc456b --- /dev/null +++ b/app/mcp/server_info_tool.rb @@ -0,0 +1,25 @@ +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:) + # 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? + 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. + payload[:email] = user.email if signed_in + + MCP::Tool::Response.new([{ type: 'text', text: JSON.generate(payload) }]) + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index 9393ea30e..2243c9ec1 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -27,11 +27,13 @@ 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 # ...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/models/api_token.rb b/app/models/api_token.rb new file mode 100644 index 000000000..cbe6f4799 --- /dev/null +++ b/app/models/api_token.rb @@ -0,0 +1,54 @@ +# 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, length: { maximum: 255 } + 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 = 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 + end + + def revoked? + revoked_at.present? + end + + def revoke! + return if revoked? + + update!(revoked_at: Time.current) + end + + def write? + scope == 'write' + end + + private + + def mint_secret + return if @raw_token.present? + + @raw_token = SecureRandom.hex(32) + self.token_digest = self.class.digest(@raw_token) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index d78bdca03..f66a888a6 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/app/views/users/_tokens.html.haml b/app/views/users/_tokens.html.haml new file mode 100644 index 000000000..090d98b9d --- /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 7a30fdd37..e52a7041b 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 @user == current_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 000000000..5104fb9c8 --- /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 b1645dcd6..d7aa6d674 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/:token_id', to: 'users#revoke_token' end end @@ -93,5 +96,7 @@ get 'about', to: 'about#show' get 'howto', to: 'about#show' + post '/mcp', to: 'mcp#create' + root 'about#index' end diff --git a/db/migrate/20261012000001_create_api_tokens.rb b/db/migrate/20261012000001_create_api_tokens.rb new file mode 100644 index 000000000..9d021a9f0 --- /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, limit: 64 + 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 8eba14745..ee34b960f 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", limit: 64, 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/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 17dc262e4..9dec40417 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -48,4 +48,104 @@ 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 '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' } + + 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/mcp/create_update_tool_spec.rb b/spec/mcp/create_update_tool_spec.rb new file mode 100644 index 000000000..3b9a65906 --- /dev/null +++ b/spec/mcp/create_update_tool_spec.rb @@ -0,0 +1,67 @@ +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 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 + 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 '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 + 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/get_project_tool_spec.rb b/spec/mcp/get_project_tool_spec.rb new file mode 100644 index 000000000..68397cdcb --- /dev/null +++ b/spec/mcp/get_project_tool_spec.rb @@ -0,0 +1,60 @@ +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 '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') + + 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/join_project_tool_spec.rb b/spec/mcp/join_project_tool_spec.rb new file mode 100644 index 000000000..d5ef207da --- /dev/null +++ b/spec/mcp/join_project_tool_spec.rb @@ -0,0 +1,47 @@ +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 '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) + + 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/list_episodes_tool_spec.rb b/spec/mcp/list_episodes_tool_spec.rb new file mode 100644 index 000000000..c004d06a6 --- /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 000000000..2b68472b0 --- /dev/null +++ b/spec/mcp/list_projects_tool_spec.rb @@ -0,0 +1,84 @@ +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 'returns an error for an unknown episode' do + create(:idea) + + response = call_tool(described_class, { episode: 999_999 }, user) + + expect(response.error?).to be(true) + 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) + + 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/my_projects_tool_spec.rb b/spec/mcp/my_projects_tool_spec.rb new file mode 100644 index 000000000..de7f8e6d0 --- /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 diff --git a/spec/mcp/search_projects_tool_spec.rb b/spec/mcp/search_projects_tool_spec.rb new file mode 100644 index 000000000..a7a81f16c --- /dev/null +++ b/spec/mcp/search_projects_tool_spec.rb @@ -0,0 +1,59 @@ +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 + + 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 diff --git a/spec/mcp/spec_helpers.rb b/spec/mcp/spec_helpers.rb new file mode 100644 index 000000000..8a199971a --- /dev/null +++ b/spec/mcp/spec_helpers.rb @@ -0,0 +1,28 @@ +module McpSpecHelpers + DENIED_MESSAGE = 'Not accessible through this server'.freeze + + def call_tool(tool, args, user, scope: nil) + delivered = JSON.parse(JSON.generate(args), symbolize_names: true) + effective = scope + effective = 'write' if effective.nil? && user.respond_to?(:persisted?) && user.persisted? + tool.call(**delivered, server_context: { user: user, scope: effective }) + 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 diff --git a/spec/models/api_token_spec.rb b/spec/models/api_token_spec.rb new file mode 100644 index 000000000..47edc5594 --- /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 diff --git a/spec/requests/mcp_spec.rb b/spec/requests/mcp_spec.rb new file mode 100644 index 000000000..6c89ee34e --- /dev/null +++ b/spec/requests/mcp_spec.rb @@ -0,0 +1,394 @@ +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, 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 + response.parsed_body['result'] + end + + 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: {}, + clientInfo: { name: 'spec', version: '0.1' } } }) + + expect(response).to have_http_status(:ok) + 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: {} }) + + 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) + 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 + 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(JSON.parse(text)).to include('episode' => '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 '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' } + + 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 'authenticates the bearer only once per request' do + 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 + 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 'leaves last_used_at alone within a minute' do + tool_call('server_info', {}, read_raw) + 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 + + 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) + end + + 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 + + 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 + + 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 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 000000000..3f829ec71 --- /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