diff --git a/OAUTH_NEXT.md b/OAUTH_NEXT.md new file mode 100644 index 000000000..856738296 --- /dev/null +++ b/OAUTH_NEXT.md @@ -0,0 +1,199 @@ +# Fizzy OAuth 2.1 + MCP + +OAuth for Fizzy. One table, one column, a handful of small controllers. MCP support included. + +--- + +## The Insight + +Fizzy's `Identity::AccessToken` is already perfect: + +```ruby +class Identity::AccessToken < ApplicationRecord + belongs_to :identity + has_secure_token + enum :permission, %w[ read write ].index_by(&:itself), default: :read + + def allows?(method) + method.in?(%w[ GET HEAD ]) || write? + end +end +``` + +**10 lines.** Don't replace it. Extend it. + +--- + +## What We Add + +| Addition | Type | Purpose | +|----------|------|---------| +| `oauth_clients` | table | Client registry (MCP DCR, first-party) | +| `oauth_client_id` | column | Links access tokens to OAuth clients | + +That's it. One table. One column. + +- **PATs stay PATs** — tokens with `oauth_client_id = nil` +- **OAuth tokens are PATs with a client** — `oauth_client_id` is set +- **Bearer auth works unchanged** — the `Authentication` concern already uses `Identity::AccessToken` + +--- + +## Authorization Codes: Stateless + +No table. Rails primitives only. + +```ruby +module Oauth::AuthorizationCode + Details = Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope) + + class << self + def generate(client_id:, identity_id:, code_challenge:, redirect_uri:, scope:) + encryptor.encrypt_and_sign( + { c: client_id, i: identity_id, h: code_challenge, r: redirect_uri, s: scope }, + expires_in: 60.seconds + ) + end + + def parse(code) + return nil if code.blank? + data = encryptor.decrypt_and_verify(code) + return nil if data.nil? + Details.new( + client_id: data["c"], + identity_id: data["i"], + code_challenge: data["h"], + redirect_uri: data["r"], + scope: data["s"] + ) + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def valid_pkce?(code_data, code_verifier) + return false if code_data.nil? || code_verifier.blank? + expected = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + ActiveSupport::SecurityUtils.secure_compare(expected, code_data.code_challenge) + end + + private + def encryptor + @encryptor ||= ActiveSupport::MessageEncryptor.new( + Rails.application.key_generator.generate_key("oauth/authorization_codes", 32) + ) + end + end +end +``` + +- 60-second TTL + PKCE-bound +- No database = no cleanup job + +--- + +## Grants: Implicit + +No `oauth_authorizations` table. + +A "grant" is just "a token exists for this client + identity." Revocation = delete tokens. + +"Connected Apps" UI at `/my/connected_apps`: + +```ruby +# List apps +current_identity.access_tokens.where.not(oauth_client: nil).includes(:oauth_client).group_by(&:oauth_client) + +# Disconnect an app (revoke all tokens for that client) +current_identity.access_tokens.where(oauth_client: client).destroy_all +``` + +--- + +## Scopes + +OAuth scopes are space-delimited (e.g., `"read write"`). We map to `Identity::AccessToken#permission`: + +- If `"write"` is in the scope list → `permission: "write"` +- Otherwise → `permission: "read"` + +The token response returns the granted scopes as a space-delimited string. + +--- + +## Token Lifetime + +Access tokens **do not expire**. This matches PAT behavior and keeps the implementation simple: + +- No refresh tokens needed +- No background jobs to clean up expired tokens +- Revocation is explicit: via `/oauth/revocation` endpoint or "Connected Apps" UI + +If expiration is needed later, add an `expires_at` column to `identity_access_tokens` and return `expires_in` in the token response. The revocation endpoint already handles cleanup. + +--- + +## Routes + +```ruby +get "/.well-known/oauth-authorization-server", to: "oauth/metadata#show" +get "/.well-known/oauth-protected-resource", to: "oauth/protected_resource_metadata#show" + +namespace :oauth do + resources :clients, only: :create # POST /oauth/clients (DCR) + resource :authorization, only: %i[ new create ] # GET/POST /oauth/authorization + resource :token, only: :create # POST /oauth/token + resource :revocation, only: :create # POST /oauth/revocation +end +``` + +Two well-known endpoints for discovery. Singular resources for OAuth protocol endpoints. Plural for the client registry. + +--- + +## Redirect URI Matching + +Per RFC 8252, loopback clients get port flexibility: + +- Registered: `http://127.0.0.1:8888/callback` +- Allowed: `http://127.0.0.1:9999/callback` (different port, same path) +- Allowed: `http://localhost:7777/callback` (different loopback host) + +Non-loopback clients require exact string match. + +DCR clients are restricted to loopback URIs only (http, not https). + +--- + +## Security + +- **Short-lived, PKCE-bound codes**: 60 seconds, S256 only +- **Loopback-only DCR**: MCP clients must use `127.0.0.1`, `localhost`, or `[::1]` +- **PKCE required**: no "plain" method +- **Port-flexible loopback matching**: per RFC 8252 +- **Rate limited**: DCR (10/min), token exchange (20/min) + +--- + +## Standards + +- RFC 6749 (OAuth 2.0) +- RFC 6750 (Bearer tokens) +- RFC 7636 (PKCE, S256 only) +- RFC 7591 (DCR subset) +- RFC 8414 (AS Discovery) +- RFC 8252 (Loopback redirects) +- RFC 9728 (Protected Resource Metadata) + +--- + +## Why This Over "Proper OAuth" + +| "Proper" OAuth | This | +|----------------|------| +| 4 tables | 1 table + 1 column | +| Migrate PATs | PATs stay | +| Stored auth codes | Stateless | +| Explicit grant table | Implicit | +| ~600 lines | ~350 lines | + +Both are correct. This one is half the code. diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb new file mode 100644 index 000000000..621a35c6c --- /dev/null +++ b/app/controllers/mcp_controller.rb @@ -0,0 +1,118 @@ +class McpController < ApplicationController + include Mcp::Protocol + + disallow_account_scope + allow_unauthenticated_access + before_action :require_bearer_token, only: :create + + def discovery + render json: { + name: "Fizzy", + description: "Kanban workflow management", + mcp_version: Mcp::PROTOCOL_VERSION, + capabilities: { tools: {}, resources: {} }, + oauth: { server: oauth_authorization_server_url } + } + end + + def create + case jsonrpc_method + when "initialize" then handle_initialize + when "tools/list" then handle_tools_list + when "tools/call" then handle_tools_call + when "resources/list" then handle_resources_list + when "resources/read" then handle_resources_read + else + jsonrpc_error :method_not_found + end + rescue ActiveRecord::RecordNotFound => e + jsonrpc_error :invalid_params, "Record not found: #{e.message}" + rescue ActiveRecord::RecordInvalid => e + jsonrpc_error :invalid_params, e.message + rescue ArgumentError => e + jsonrpc_error :invalid_params, e.message + end + + private + def handle_initialize + client_version = jsonrpc_params[:protocolVersion] + + negotiated_version = if Mcp::SUPPORTED_VERSIONS.include?(client_version) + client_version + else + Mcp::PROTOCOL_VERSION + end + + jsonrpc_response({ + protocolVersion: negotiated_version, + capabilities: { tools: {}, resources: {} }, + serverInfo: { name: "Fizzy", title: "Fizzy Kanban", version: "1.0.0" } + }) + end + + def handle_tools_list + jsonrpc_response Mcp::Tools.list + end + + def handle_tools_call + name = jsonrpc_params[:name] + arguments = jsonrpc_params[:arguments]&.permit!&.to_h || {} + + result = Mcp::Tools.call(name, arguments, identity: Current.identity) + jsonrpc_response result + end + + def handle_resources_list + jsonrpc_response Mcp::Resources.list + end + + def handle_resources_read + uri = jsonrpc_params[:uri] + result = Mcp::Resources.read(uri, identity: Current.identity) + jsonrpc_response result + end + + def require_bearer_token + if token = request.authorization.to_s[/\ABearer (.+)\z/i, 1] + if access_token = Identity::AccessToken.find_by(token: token) + if access_token.allows_operation?(mcp_operation) + Current.identity = access_token.identity + return + else + response.headers["WWW-Authenticate"] = %(Bearer error="insufficient_scope") + head :forbidden and return + end + end + end + + response.headers["WWW-Authenticate"] = %(Bearer resource_metadata="#{oauth_protected_resource_url}") + head :unauthorized + end + + def mcp_operation + case jsonrpc_method + when "tools/call" then :write + else :read + end + end + + def oauth_protected_resource_url + Rails.application.routes.url_helpers.url_for \ + controller: "oauth/protected_resource_metadata", + action: "show", + only_path: false, + host: request.host, + port: request.port, + protocol: request.protocol + end + + def oauth_authorization_server_url + Rails.application.routes.url_helpers.url_for \ + controller: "oauth/metadata", + action: "show", + only_path: false, + host: request.host, + port: request.port, + protocol: request.protocol + end +end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bb605e0ac..53f10da89 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -8,9 +8,12 @@ class Oauth::AuthorizationsController < Oauth::BaseController before_action :validate_pkce before_action :validate_scope before_action :validate_state + before_action :allow_oauth_redirect_in_csp def new - @scope = params[:scope].presence || "read" + # Normalize scope: if "write" is requested, use "write" (which implies read) + requested_scopes = params[:scope].to_s.split + @scope = requested_scopes.include?("write") ? "write" : "read" @redirect_uri = params[:redirect_uri] @state = params[:state] @code_challenge = params[:code_challenge] @@ -105,4 +108,15 @@ def build_redirect_uri(base, **query_params) uri.query = URI.encode_www_form(query) uri.to_s end + + # Safari blocks form submission redirects to URLs not in form-action CSP. + # Add the validated redirect_uri to allow the OAuth callback redirect. + def allow_oauth_redirect_in_csp + return unless params[:redirect_uri].present? + + redirect_origin = URI.parse(params[:redirect_uri]).then { "#{_1.scheme}://#{_1.host}:#{_1.port}" } + request.content_security_policy.form_action :self, redirect_origin + rescue URI::InvalidURIError + # Invalid URI will be caught by validate_redirect_uri + end end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index 6cd1fc3b2..bbbfefb10 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -11,4 +11,8 @@ class Identity::AccessToken < ApplicationRecord def allows?(method) method.in?(%w[ GET HEAD ]) || write? end + + def allows_operation?(operation) + operation == :read || write? + end end diff --git a/app/models/mcp.rb b/app/models/mcp.rb new file mode 100644 index 000000000..397e138a3 --- /dev/null +++ b/app/models/mcp.rb @@ -0,0 +1,4 @@ +module Mcp + PROTOCOL_VERSION = "2025-06-18" + SUPPORTED_VERSIONS = %w[ 2025-06-18 2025-03-26 ].freeze +end diff --git a/app/models/mcp/protocol.rb b/app/models/mcp/protocol.rb new file mode 100644 index 000000000..42e6872a0 --- /dev/null +++ b/app/models/mcp/protocol.rb @@ -0,0 +1,77 @@ +module Mcp::Protocol + extend ActiveSupport::Concern + + JSONRPC_VERSION = "2.0" + + included do + before_action :parse_jsonrpc_request, only: :create + before_action :validate_protocol_version, only: :create + end + + private + attr_reader :jsonrpc_id, :jsonrpc_method, :jsonrpc_params, :protocol_version + + def notification? + !params.key?(:id) + end + + def parse_jsonrpc_request + @jsonrpc_id = params[:id] + @jsonrpc_method = params[:method] + @jsonrpc_params = params[:params] || {} + end + + def validate_protocol_version + # Initialize doesn't require version header (it's where version is negotiated) + return if jsonrpc_method == "initialize" + + version = request.headers["MCP-Protocol-Version"] + + # Per spec: if no header and no other way to identify, assume 2025-03-26 + @protocol_version = version.presence || "2025-03-26" + + unless Mcp::SUPPORTED_VERSIONS.include?(@protocol_version) + jsonrpc_error :invalid_request, "Unsupported protocol version: #{@protocol_version}" + end + end + + def jsonrpc_response(result) + # JSON-RPC 2.0: Notifications don't receive responses + return head(:accepted) if notification? + + render json: { + jsonrpc: JSONRPC_VERSION, + id: jsonrpc_id, + result: result + } + end + + def jsonrpc_error(code, message = nil, data: nil) + # JSON-RPC 2.0: Notifications don't receive responses (even errors) + return head(:accepted) if notification? + error = case code + when :parse_error then { code: -32700, message: message || "Parse error" } + when :invalid_request then { code: -32600, message: message || "Invalid request" } + when :method_not_found then { code: -32601, message: message || "Method not found" } + when :invalid_params then { code: -32602, message: message || "Invalid params" } + when :internal_error then { code: -32603, message: message || "Internal error" } + else { code: code, message: message } + end + + error[:data] = data if data.present? + + render json: { + jsonrpc: JSONRPC_VERSION, + id: jsonrpc_id, + error: error + }, status: error_status(code) + end + + def error_status(code) + case code + when :parse_error, :invalid_request, :invalid_params then :bad_request + when :method_not_found then :not_found + else :internal_server_error + end + end +end diff --git a/app/models/mcp/resources.rb b/app/models/mcp/resources.rb new file mode 100644 index 000000000..9d716185f --- /dev/null +++ b/app/models/mcp/resources.rb @@ -0,0 +1,186 @@ +module Mcp::Resources + extend self + + RESOURCES = [ + { + uri: "fizzy://accounts", + name: "accounts", + title: "Available Accounts", + description: "List of accounts accessible to the authenticated identity", + mimeType: "application/json" + }, + { + uriTemplate: "fizzy://accounts/{account_id}/overview", + name: "overview", + title: "Workspace Overview", + description: "Summary of boards and recent activity for an account", + mimeType: "application/json" + }, + { + uriTemplate: "fizzy://accounts/{account_id}/boards/{id}", + name: "board", + title: "Board Details", + description: "Board with columns and cards summary", + mimeType: "application/json" + }, + { + uriTemplate: "fizzy://accounts/{account_id}/cards/{number}", + name: "card", + title: "Card Details", + description: "Full card with comments and steps", + mimeType: "application/json" + } + ] + + def list + { resources: RESOURCES } + end + + def read(uri, identity:) + case uri + when "fizzy://accounts" + accounts(identity) + when %r{\Afizzy://accounts/([^/]+)/overview\z} + with_account($1, identity) { overview } + when %r{\Afizzy://accounts/([^/]+)/boards/(.+)\z} + with_account($1, identity) { board($2) } + when %r{\Afizzy://accounts/([^/]+)/cards/(\d+)\z} + with_account($1, identity) { card($2) } + else + raise ArgumentError, "Unknown resource: #{uri}" + end + end + + private + def with_account(account_id, identity) + account = identity.accounts.find_by!(id: account_id) + user = identity.users.find_by!(account: account) + + Current.account = account + Current.user = user + + yield + rescue ActiveRecord::RecordNotFound + raise ArgumentError, "Account not found or not accessible: #{account_id}" + end + + def accounts(identity) + { + contents: [ { + uri: "fizzy://accounts", + mimeType: "application/json", + text: { + accounts: identity.accounts.map { |a| + { id: a.id, name: a.name } + } + }.to_json + } ] + } + end + + def overview + { + contents: [ { + uri: "fizzy://accounts/#{Current.account.id}/overview", + mimeType: "application/json", + text: overview_content.to_json + } ] + } + end + + def overview_content + { + account: { id: Current.account.id, name: Current.account.name }, + boards: Current.user.boards.includes(:columns).map { |b| + { + id: b.id, + name: b.name, + columns: b.columns.sorted.map(&:name), + card_count: b.cards.count + } + }, + in_progress: in_progress_cards, + recent_activity: recent_activity + } + end + + def in_progress_cards + Current.user.accessible_cards + .joins(:column) + .where(columns: { name: [ "In Progress", "In progress", "Doing", "Active" ] }) + .limit(10) + .map { |c| card_summary(c) } + end + + def recent_activity + Current.user.accessible_cards + .order(updated_at: :desc) + .limit(5) + .map { |c| card_summary(c) } + end + + def board(id) + board = Current.user.boards.includes(:columns).find(id) + + { + contents: [ { + uri: "fizzy://accounts/#{Current.account.id}/boards/#{id}", + mimeType: "application/json", + text: board_content(board).to_json + } ] + } + end + + def board_content(board) + { + id: board.id, + name: board.name, + columns: board.columns.sorted.map { |col| + { + id: col.id, + name: col.name, + card_count: col.cards.count, + cards: col.cards.limit(5).map { |c| card_summary(c) } + } + } + } + end + + def card(number) + card = Current.user.accessible_cards.find_by!(number: number) + + { + contents: [ { + uri: "fizzy://accounts/#{Current.account.id}/cards/#{number}", + mimeType: "application/json", + text: card_content(card).to_json + } ] + } + end + + def card_content(card) + { + number: card.number, + title: card.title, + description: card.description&.to_plain_text, + board: card.board.name, + column: card.column&.name, + created_at: card.created_at.iso8601, + updated_at: card.updated_at.iso8601, + assignees: card.assignees.map(&:name), + steps: card.steps.map { |s| { text: s.description, done: s.checked? } }, + comments: card.comments.limit(20).map { |c| + { author: c.creator.name, text: c.body.to_plain_text, at: c.created_at.iso8601 } + } + } + end + + def card_summary(card) + { + number: card.number, + title: card.title, + board: card.board.name, + column: card.column&.name + } + end +end diff --git a/app/models/mcp/tools.rb b/app/models/mcp/tools.rb new file mode 100644 index 000000000..4b1352f1d --- /dev/null +++ b/app/models/mcp/tools.rb @@ -0,0 +1,226 @@ +module Mcp::Tools + extend self + + TOOLS = [ + { + name: "create_board", + title: "Create Board", + description: "Create a new board for organizing work", + inputSchema: { + type: "object", + properties: { + account: { type: "string", description: "Account ID (required)" }, + name: { type: "string", description: "Board name" }, + columns: { type: "array", items: { type: "string" }, description: "Column names (default: Backlog, In Progress, Done)" } + }, + required: %w[ account name ] + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + } + }, + { + name: "create_card", + title: "Create Card", + description: "Create a new card on a board", + inputSchema: { + type: "object", + properties: { + account: { type: "string", description: "Account ID (required)" }, + title: { type: "string", description: "What needs to be done" }, + board: { type: "string", description: "Board name or ID (optional, uses most recent)" }, + description: { type: "string", description: "Details, context, acceptance criteria" } + }, + required: %w[ account title ] + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + } + }, + { + name: "update_card", + title: "Update Card", + description: "Update a card or add a comment", + inputSchema: { + type: "object", + properties: { + account: { type: "string", description: "Account ID (required)" }, + card: { type: "string", description: "Card number (e.g. '123' or '#123')" }, + title: { type: "string", description: "New title" }, + description: { type: "string", description: "New description" }, + comment: { type: "string", description: "Add a comment to the card" } + }, + required: %w[ account card ] + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + } + }, + { + name: "move_card", + title: "Move Card", + description: "Move a card to a different column", + inputSchema: { + type: "object", + properties: { + account: { type: "string", description: "Account ID (required)" }, + card: { type: "string", description: "Card number" }, + to: { type: "string", description: "Column name, or: 'next', 'done', 'backlog'" } + }, + required: %w[ account card to ] + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } + } + ] + + def list + { tools: TOOLS } + end + + def call(name, arguments, identity:) + args = arguments.to_h.symbolize_keys + + # Validate and set account context + account = resolve_account(args.delete(:account), identity) + user = identity.users.find_by!(account: account) + + Current.account = account + Current.user = user + + case name + when "create_board" then create_board(**args) + when "create_card" then create_card(**args) + when "update_card" then update_card(**args) + when "move_card" then move_card(**args) + else + raise ArgumentError, "Unknown tool: #{name}" + end + end + + private + def resolve_account(account_id, identity) + raise ArgumentError, "account is required" if account_id.blank? + + identity.accounts.find_by!(id: account_id) + rescue ActiveRecord::RecordNotFound + raise ArgumentError, "Account not found or not accessible: #{account_id}" + end + + def create_board(name:, columns: nil) + columns ||= [ "Backlog", "In Progress", "Done" ] + + board = Current.user.account.boards.create!(name: name, creator: Current.user, all_access: true) + columns.each { |col| board.columns.create!(name: col) } + + tool_result board_summary(board) + end + + def create_card(title:, board: nil, description: nil) + board = resolve_board(board) + card = board.cards.create! \ + title: title, + description: description, + creator: Current.user, + status: "published" + + tool_result card_summary(card) + end + + def update_card(card:, title: nil, description: nil, comment: nil) + card = find_card(card) + + card.update!(title: title) if title.present? + card.update!(description: description) if description.present? + card.comments.create!(body: comment, creator: Current.user) if comment.present? + + tool_result card_summary(card.reload) + end + + def move_card(card:, to:) + card = find_card(card) + column = resolve_column(card.board, to, card.column) + card.update!(column: column) + + tool_result card_summary(card) + end + + # Helpers + + def resolve_board(identifier) + return Current.user.boards.order(updated_at: :desc).first! if identifier.blank? + + Current.user.boards.find_by(id: identifier) || + Current.user.boards.find_by!(name: identifier) + end + + def find_card(identifier) + number = identifier.to_s.delete_prefix("#") + Current.user.accessible_cards.find_by!(number: number) + end + + def resolve_column(board, target, current_column) + case target.to_s.downcase + when "done", "complete" + board.columns.sorted.last + when "backlog" + board.columns.sorted.first + when "next" + if current_column + current_column.right_column || board.columns.sorted.last + else + board.columns.sorted.second || board.columns.sorted.first + end + else + board.columns.find_by!(name: target) + end + end + + def tool_result(content) + { + content: [ { type: "text", text: content.to_json } ] + } + end + + def board_summary(board) + { + id: board.id, + name: board.name, + columns: board.columns.sorted.pluck(:name), + url: url_for(board) + } + end + + def card_summary(card) + { + number: card.number, + title: card.title, + board: card.board.name, + column: card.column&.name, + url: url_for(card) + } + end + + def url_for(record) + Rails.application.routes.url_helpers.polymorphic_url(record, + script_name: Current.account.slug, + **url_options) + end + + def url_options + Rails.application.config.action_mailer.default_url_options || { host: "localhost" } + end +end diff --git a/config/routes.rb b/config/routes.rb index 756cc41ad..7eee031d1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,9 @@ get "/.well-known/oauth-authorization-server", to: "oauth/metadata#show" get "/.well-known/oauth-protected-resource", to: "oauth/protected_resource_metadata#show" + get "/.well-known/mcp.json", to: "mcp#discovery" + + post "/mcp", to: "mcp#create" namespace :oauth do resource :authorization, only: %i[ new create ] diff --git a/test/integration/mcp_test.rb b/test/integration/mcp_test.rb new file mode 100644 index 000000000..c4f9ed882 --- /dev/null +++ b/test/integration/mcp_test.rb @@ -0,0 +1,453 @@ +require "test_helper" + +class McpTest < ActionDispatch::IntegrationTest + setup do + @bearer_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:davids_api_token).token}" } + @read_only_token = { "HTTP_AUTHORIZATION" => "Bearer #{identity_access_tokens(:jasons_api_token).token}" } + @account = accounts("37s") + end + + + # Discovery + + test "discovery returns server metadata" do + untenanted do + get "/.well-known/mcp.json" + end + + assert_response :success + body = response.parsed_body + + assert_equal "Fizzy", body["name"] + assert_equal "2025-06-18", body["mcp_version"] + assert body["capabilities"].key?("tools") + assert body["capabilities"].key?("resources") + assert body["oauth"]["server"].present? + end + + + # Initialize + + test "initialize returns protocol info" do + jsonrpc_call "initialize" + + assert_response :success + result = response.parsed_body["result"] + + assert_equal "2025-06-18", result["protocolVersion"] + assert_equal "Fizzy", result["serverInfo"]["name"] + assert_equal "Fizzy Kanban", result["serverInfo"]["title"] + assert result["capabilities"].key?("tools") + assert result["capabilities"].key?("resources") + end + + + # Protocol version header + + test "requests require MCP-Protocol-Version header" do + jsonrpc_call "tools/list" + + assert_response :success + end + + test "unsupported protocol version returns error" do + untenanted do + post "/mcp", + params: jsonrpc_request("tools/list"), + headers: @bearer_token.merge("MCP-Protocol-Version" => "1999-01-01"), + as: :json + end + + assert_response :bad_request + error = response.parsed_body["error"] + assert_match "Unsupported protocol version", error["message"] + end + + test "notifications (requests without id) return 202 accepted" do + untenanted do + post "/mcp", + params: { jsonrpc: "2.0", method: "notifications/initialized" }, + headers: @bearer_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION), + as: :json + end + + assert_response :accepted + assert_empty response.body + end + + test "notifications execute side effects before returning 202" do + board = boards(:writebook) + + assert_difference "board.cards.count", 1 do + untenanted do + post "/mcp", + params: { + jsonrpc: "2.0", + method: "tools/call", + params: { name: "create_card", arguments: { account: @account.id, board: board.id, title: "Created via notification" } } + }, + headers: @bearer_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION), + as: :json + end + end + + assert_response :accepted + assert_empty response.body + assert_equal "Created via notification", board.cards.last.title + end + + + # Tools + + test "tools/list returns available tools with annotations" do + jsonrpc_call "tools/list" + + assert_response :success + tools = response.parsed_body["result"]["tools"] + + tool_names = tools.map { |t| t["name"] } + assert_includes tool_names, "create_board" + assert_includes tool_names, "create_card" + assert_includes tool_names, "update_card" + assert_includes tool_names, "move_card" + + # Check tool has title and annotations + create_board = tools.find { |t| t["name"] == "create_board" } + assert_equal "Create Board", create_board["title"] + assert create_board["annotations"].key?("readOnlyHint") + assert create_board["annotations"].key?("destructiveHint") + end + + test "tools/call create_board creates a new board" do + assert_difference "Board.count", 1 do + jsonrpc_call "tools/call", name: "create_board", arguments: { account: @account.id, name: "Agent Workspace" } + end + + assert_response :success + content = JSON.parse(response.parsed_body.dig("result", "content", 0, "text")) + + assert_equal "Agent Workspace", content["name"] + assert_includes content["columns"], "Backlog" + assert_includes content["columns"], "In Progress" + assert_includes content["columns"], "Done" + end + + test "tools/call create_board with custom columns" do + jsonrpc_call "tools/call", name: "create_board", arguments: { + account: @account.id, + name: "Custom Flow", + columns: [ "Queue", "Active", "Complete" ] + } + + assert_response :success + content = JSON.parse(response.parsed_body.dig("result", "content", 0, "text")) + + assert_equal [ "Queue", "Active", "Complete" ], content["columns"] + end + + test "tools/call create_card creates a new card" do + board = boards(:writebook) + + assert_difference "Card.count", 1 do + jsonrpc_call "tools/call", name: "create_card", arguments: { + account: @account.id, + title: "New feature request", + board: board.name, + description: "Detailed description here" + } + end + + assert_response :success + content = JSON.parse(response.parsed_body.dig("result", "content", 0, "text")) + + assert_equal "New feature request", content["title"] + assert_equal "Writebook", content["board"] + end + + test "tools/call create_card uses most recent board when not specified" do + assert_difference "Card.count", 1 do + jsonrpc_call "tools/call", name: "create_card", arguments: { + account: @account.id, + title: "Quick card" + } + end + + assert_response :success + end + + test "tools/call without account returns error" do + jsonrpc_call "tools/call", name: "create_board", arguments: { name: "No Account" } + + assert_response :bad_request + error = response.parsed_body["error"] + assert_match "account is required", error["message"] + end + + test "tools/call with invalid account returns error" do + jsonrpc_call "tools/call", name: "create_board", arguments: { + account: "00000000-0000-0000-0000-000000000000", + name: "Invalid Account" + } + + assert_response :bad_request + error = response.parsed_body["error"] + assert_match "Account not found", error["message"] + end + + test "tools/call update_card updates title" do + card = cards(:logo) + + jsonrpc_call "tools/call", name: "update_card", arguments: { + account: @account.id, + card: card.number.to_s, + title: "Updated title" + } + + assert_response :success + assert_equal "Updated title", card.reload.title + end + + test "tools/call update_card adds comment" do + card = cards(:logo) + + assert_difference "Comment.count", 1 do + jsonrpc_call "tools/call", name: "update_card", arguments: { + account: @account.id, + card: "##{card.number}", + comment: "Progress update from agent" + } + end + + assert_response :success + assert_equal "Progress update from agent", card.comments.last.body.to_plain_text + end + + test "tools/call move_card moves to column by name" do + card = cards(:logo) + assert_equal "Triage", card.column.name + + jsonrpc_call "tools/call", name: "move_card", arguments: { + account: @account.id, + card: card.number.to_s, + to: "In progress" + } + + assert_response :success + assert_equal "In progress", card.reload.column.name + end + + test "tools/call move_card moves to done" do + card = cards(:logo) + + jsonrpc_call "tools/call", name: "move_card", arguments: { + account: @account.id, + card: card.number.to_s, + to: "done" + } + + assert_response :success + assert_equal "Review", card.reload.column.name # Last column + end + + test "tools/call move_card moves to backlog" do + card = cards(:text) + assert_equal "In progress", card.column.name + + jsonrpc_call "tools/call", name: "move_card", arguments: { + account: @account.id, + card: card.number.to_s, + to: "backlog" + } + + assert_response :success + assert_equal "Triage", card.reload.column.name # First column + end + + test "tools/call move_card moves to next column" do + card = cards(:logo) + assert_equal "Triage", card.column.name + + jsonrpc_call "tools/call", name: "move_card", arguments: { + account: @account.id, + card: card.number.to_s, + to: "next" + } + + assert_response :success + assert_equal "In progress", card.reload.column.name + end + + test "tools/call with read-only token fails for write operations" do + untenanted do + post "/mcp", + params: jsonrpc_request("tools/call", name: "create_board", arguments: { account: @account.id, name: "Should fail" }), + headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION), + as: :json + end + + assert_response :forbidden + end + + test "tools/list succeeds with read-only token" do + untenanted do + post "/mcp", + params: jsonrpc_request("tools/list"), + headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION), + as: :json + end + + assert_response :success + assert response.parsed_body["result"]["tools"].is_a?(Array) + end + + test "resources/list succeeds with read-only token" do + untenanted do + post "/mcp", + params: jsonrpc_request("resources/list"), + headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION), + as: :json + end + + assert_response :success + assert response.parsed_body["result"]["resources"].is_a?(Array) + end + + test "resources/read succeeds with read-only token" do + untenanted do + post "/mcp", + params: jsonrpc_request("resources/read", uri: "fizzy://accounts"), + headers: @read_only_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION), + as: :json + end + + assert_response :success + assert response.parsed_body["result"]["contents"].is_a?(Array) + end + + + # Resources + + test "resources/list returns available resources" do + jsonrpc_call "resources/list" + + assert_response :success + resources = response.parsed_body["result"]["resources"] + + uris = resources.map { |r| r["uri"] || r["uriTemplate"] } + assert_includes uris, "fizzy://accounts" + assert_includes uris, "fizzy://accounts/{account_id}/overview" + assert_includes uris, "fizzy://accounts/{account_id}/boards/{id}" + assert_includes uris, "fizzy://accounts/{account_id}/cards/{number}" + + # Check resources have title + accounts_resource = resources.find { |r| r["uri"] == "fizzy://accounts" } + assert_equal "Available Accounts", accounts_resource["title"] + end + + test "resources/read accounts returns list of accessible accounts" do + jsonrpc_call "resources/read", uri: "fizzy://accounts" + + assert_response :success + content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text")) + + assert content["accounts"].is_a?(Array) + account_names = content["accounts"].map { |a| a["name"] } + assert_includes account_names, "37signals" + end + + test "resources/read overview returns boards and activity" do + jsonrpc_call "resources/read", uri: "fizzy://accounts/#{@account.id}/overview" + + assert_response :success + content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text")) + + assert_equal @account.id, content["account"]["id"] + assert content["boards"].is_a?(Array) + assert content["in_progress"].is_a?(Array) + assert content["recent_activity"].is_a?(Array) + end + + test "resources/read board returns board details" do + board = boards(:writebook) + + jsonrpc_call "resources/read", uri: "fizzy://accounts/#{@account.id}/boards/#{board.id}" + + assert_response :success + content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text")) + + assert_equal board.id, content["id"] + assert_equal "Writebook", content["name"] + assert content["columns"].is_a?(Array) + end + + test "resources/read card returns card details" do + card = cards(:logo) + + jsonrpc_call "resources/read", uri: "fizzy://accounts/#{@account.id}/cards/#{card.number}" + + assert_response :success + content = JSON.parse(response.parsed_body.dig("result", "contents", 0, "text")) + + assert_equal card.number, content["number"] + assert_equal card.title, content["title"] + end + + test "resources/read with invalid account returns error" do + jsonrpc_call "resources/read", uri: "fizzy://accounts/00000000-0000-0000-0000-000000000000/overview" + + assert_response :bad_request + error = response.parsed_body["error"] + assert_match "Account not found", error["message"] + end + + + # Error handling + + test "unknown method returns method_not_found error" do + jsonrpc_call "unknown/method" + + assert_response :not_found + error = response.parsed_body["error"] + + assert_equal(-32601, error["code"]) + end + + test "unknown tool returns error" do + jsonrpc_call "tools/call", name: "unknown_tool", arguments: { account: @account.id } + + assert_response :bad_request + error = response.parsed_body["error"] + + assert_equal(-32602, error["code"]) + assert_match "Unknown tool", error["message"] + end + + test "card not found returns error" do + jsonrpc_call "tools/call", name: "move_card", arguments: { account: @account.id, card: "99999", to: "done" } + + assert_response :bad_request + error = response.parsed_body["error"] + + assert_match "not found", error["message"] + end + + + private + def jsonrpc_call(method, **params) + untenanted do + post "/mcp", + params: jsonrpc_request(method, **params), + headers: @bearer_token.merge("MCP-Protocol-Version" => Mcp::PROTOCOL_VERSION), + as: :json + end + end + + def jsonrpc_request(method, **params) + { + jsonrpc: "2.0", + id: SecureRandom.uuid, + method: method, + params: params.presence + }.compact + end +end