Repository navigation
Add native MCP endpoint with per-user API tokens #1939
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
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 f5bd652
feat(mcp): add endpoint skeleton with ServerInfoTool
plusky 90ee938
feat(mcp): add read-only project and episode tools
plusky 11e5ae8
feat(mcp): add my_projects, create_update and join_project tools
plusky 907442d
fix(mcp): read user off ServerContext as well as Hash
plusky f27a2cb
feat(mcp): add ApiToken model with digest-stored secrets
plusky 78d67ec
feat(mcp): add API token mint/revoke UI to user settings
plusky 362824f
feat(mcp): wire bearer auth and write-scope enforcement
plusky f2c12f5
test(mcp): cover bearer auth for all tools
plusky 6210168
docs(mcp): document the MCP endpoint and API tokens
plusky 10c1c4a
fix(mcp): constrain token digest and harden ApiToken lifecycle
plusky ab4dcba
fix(mcp): strip bearer whitespace before digest
plusky 8947b10
fix(mcp): gate CSRF skip on bearer and harden write check
plusky 5324b58
fix(mcp): enforce write scope defense-in-depth
plusky 175f4f6
fix(mcp): harden join and create_update authorization
plusky 40a5bc5
fix(mcp): filter read tools through Ability with safe pagination
plusky d4d766d
fix(mcp): return structured server_info payload
plusky ed07541
fix(mcp): scope token management to requester
plusky 699520e
fix(mcp): cover token ownership and invalid tool args
plusky fabe0c4
fix(mcp): satisfy rubocop on changed files
plusky d209f95
fix(mcp): use strict id check in create_update, cover float-like ids
plusky 39c01f3
fix(mcp): trust request host in transport allowlist in all envs
plusky 8576f98
fix(mcp): authenticate bearer once, ignore session, gate CSRF skip on…
plusky 688e5d0
fix(mcp): throttle last_used_at writes to one per minute
plusky f88bd71
fix(mcp): ack JSON-RPC notifications with a bare 202
plusky 5235cab
fix(mcp): restrict create_update to originators and members
plusky 8423a92
fix(mcp): simplify list_projects filtering to SQL limit plus guard
plusky e452854
fix(mcp): simplify search filtering to SQL limit plus guard
plusky 14def22
fix(mcp): drop controller write pre-check, tools deny uniformly
plusky 8ab3c37
fix(mcp): show token settings only to the profile owner
plusky 3227a80
fix(mcp): serve minted token secret with Cache-Control no-store
plusky 6aaa7b6
fix(mcp): satisfy rubocop on changed files
plusky File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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:) | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.