Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
45709c9
feat(mcp): add modelcontextprotocol ruby-sdk dependency
plusky Oct 12, 2026
f5bd652
feat(mcp): add endpoint skeleton with ServerInfoTool
plusky Oct 12, 2026
90ee938
feat(mcp): add read-only project and episode tools
plusky Oct 12, 2026
11e5ae8
feat(mcp): add my_projects, create_update and join_project tools
plusky Oct 12, 2026
907442d
fix(mcp): read user off ServerContext as well as Hash
plusky Oct 12, 2026
f27a2cb
feat(mcp): add ApiToken model with digest-stored secrets
plusky Oct 12, 2026
78d67ec
feat(mcp): add API token mint/revoke UI to user settings
plusky Oct 12, 2026
362824f
feat(mcp): wire bearer auth and write-scope enforcement
plusky Oct 12, 2026
f2c12f5
test(mcp): cover bearer auth for all tools
plusky Oct 12, 2026
6210168
docs(mcp): document the MCP endpoint and API tokens
plusky Oct 12, 2026
10c1c4a
fix(mcp): constrain token digest and harden ApiToken lifecycle
plusky Oct 12, 2026
ab4dcba
fix(mcp): strip bearer whitespace before digest
plusky Oct 12, 2026
8947b10
fix(mcp): gate CSRF skip on bearer and harden write check
plusky Oct 12, 2026
5324b58
fix(mcp): enforce write scope defense-in-depth
plusky Oct 12, 2026
175f4f6
fix(mcp): harden join and create_update authorization
plusky Oct 12, 2026
40a5bc5
fix(mcp): filter read tools through Ability with safe pagination
plusky Oct 12, 2026
d4d766d
fix(mcp): return structured server_info payload
plusky Oct 12, 2026
ed07541
fix(mcp): scope token management to requester
plusky Oct 12, 2026
699520e
fix(mcp): cover token ownership and invalid tool args
plusky Oct 12, 2026
fabe0c4
fix(mcp): satisfy rubocop on changed files
plusky Oct 12, 2026
d209f95
fix(mcp): use strict id check in create_update, cover float-like ids
plusky Oct 12, 2026
39c01f3
fix(mcp): trust request host in transport allowlist in all envs
plusky Oct 13, 2026
8576f98
fix(mcp): authenticate bearer once, ignore session, gate CSRF skip on…
plusky Oct 13, 2026
688e5d0
fix(mcp): throttle last_used_at writes to one per minute
plusky Oct 13, 2026
f88bd71
fix(mcp): ack JSON-RPC notifications with a bare 202
plusky Oct 13, 2026
5235cab
fix(mcp): restrict create_update to originators and members
plusky Oct 13, 2026
8423a92
fix(mcp): simplify list_projects filtering to SQL limit plus guard
plusky Oct 13, 2026
e452854
fix(mcp): simplify search filtering to SQL limit plus guard
plusky Oct 13, 2026
14def22
fix(mcp): drop controller write pre-check, tools deny uniformly
plusky Oct 13, 2026
8ab3c37
fix(mcp): show token settings only to the profile owner
plusky Oct 13, 2026
3227a80
fix(mcp): serve minted token secret with Cache-Control no-store
plusky Oct 13, 2026
6aaa7b6
fix(mcp): satisfy rubocop on changed files
plusky Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 10 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -487,6 +496,7 @@ DEPENDENCIES
kaminari
listen
lograge
mcp (~> 1.7)
mina
mousetrap-rails
mutex_m
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`.

## Resources
* Design mockups of the Rails app are in the design directory.
* There are some tools in the tool directory.
Expand Down
76 changes: 76 additions & 0 deletions app/controllers/mcp_controller.rb
Original file line number Diff line number Diff line change
@@ -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:)
Comment thread
plusky marked this conversation as resolved.
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
42 changes: 41 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -15,7 +17,9 @@ def index
end
end

def edit; end
def edit
@api_tokens = owner_tokens
end

def update
@user.update(params['user'])
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions app/mcp/create_update_tool.rb
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions app/mcp/get_project_tool.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading