Skip to content

Add MCP HTTP endpoint with intelligent LLM-powered tools #2415

Description

@obie

Fizzy MCP Implementation Plan

Overview

Add an intelligent MCP HTTP endpoint to Fizzy that exposes 3 semantic tools (search, read, execute) powered by Raix + Gemini Flash 3. The LLM layer interprets plain language requests and maps them to internal Fizzy operations, avoiding the 44-tool explosion from fabric-pro/fizzy-mcp.

Architecture Summary

Pattern: Follow Nexus reference implementation

  • MCP endpoint at /mcp via fast-mcp gem + custom transport middleware
  • Bearer token authentication via enhanced Identity::AccessToken model
  • 3 MCP tools that delegate to Raix-powered assistant for intent interpretation
  • Multi-tenancy via default account on token + optional override parameter
  • Simple read/write permissions (no granular resource permissions)
  • Use existing MySQL full-text search (no semantic embeddings needed)

Request Flow:

Client → POST /mcp (Bearer token)
  → FizzyMcpTransport validates token
  → FastMcp::Server routes to tool
  → Tool delegates to FizzyAssistant (Raix + Gemini Flash)
  → Assistant interprets plain language → function calls
  → Functions execute Fizzy operations in account context
  → Returns MCP-formatted JSON

Critical Files to Create

1. MCP Infrastructure

config/initializers/fast_mcp.rb

  • Create FastMcp::Server instance (name: "fizzy", version: "1.0.0")
  • Auto-register tools via ApplicationTool.descendants after Rails initialization
  • Mount FizzyMcpTransport middleware at /mcp path prefix

lib/fizzy_mcp_transport.rb

  • Inherit from FastMcp::Transports::RackTransport
  • Override handle_mcp_request to validate Bearer tokens
  • Extract token from HTTP_AUTHORIZATION header
  • Validate via Identity::AccessToken.authenticate(token)
  • Set Current.identity and resolve account (token.default_account or override)
  • Handle POST to /mcp as messages endpoint (like Nexus)
  • Use CaptureTransport pattern to return HTTP response (not SSE)
  • Return 401 with OAuth URL if unauthorized

2. Enhanced Authentication Model

db/migrate/xxx_enhance_identity_access_tokens_for_mcp.rb

  • Add columns: name:string, prefix:string (indexed), expires_at:datetime, last_used_at:datetime, default_account_id:uuid
  • Add foreign key: default_account_idaccounts.id
  • Generate prefix on creation (first 8 chars of token for fast lookup)

app/models/identity/access_token.rb (modify)

  • Add belongs_to :default_account, class_name: "Account", optional: true
  • Add before_create :set_prefix callback
  • Add .authenticate(token) class method (lookup by prefix, verify with BCrypt)
  • Add #expired? method
  • Add #touch_last_used! method
  • Validate uniqueness of prefix

3. MCP Tools (3 tools)

app/tools/application_tool.rb

  • Inherit from ActionTool::Base
  • Define SERVER_CONTEXT constant describing Fizzy
  • Protected #json_result(data) helper: wraps in MCP format {content: [{type: "text", text: data.to_json}], isError: false}
  • Protected #current_user, #current_account accessors
  • HATEOAS hint helpers: #card_url_hint, #board_url_hint, etc.

app/tools/fizzy_search_tool.rb

  • Description: "Find cards, boards, users by natural language query with filters"
  • Arguments: query:string (required), account_id:integer (optional), entity_type:string, limit:integer (default: 20)
  • Implementation:
    • Resolve account from account_id param or Current.identity.default_account
    • Delegate to existing Search::Query with user scope
    • Return array of results with type, id, title, snippet, URL
    • Include HATEOAS hints for drilling down (use fizzy_read)

app/tools/fizzy_read_tool.rb

  • Description: "Get specific entity by identifier (card number, board/user ID)"
  • Arguments: entity_type:string (card/board/user/comment), identifier:string, account_id:integer (optional)
  • Implementation:
    • Case switch on entity_type
    • Card: find by number via Current.user.accessible_cards.find_by!(number: identifier)
    • Board: find via Current.user.boards.find(identifier)
    • User: find via Current.account.users.find(identifier)
    • Comment: find via Current.user.accessible_comments.find(identifier)
    • Return rich JSON (reuse JBuilder template patterns)
    • Include related entity URLs, HATEOAS hints for actions

app/tools/fizzy_execute_tool.rb

  • Description: "Perform actions via natural language: create, update, move, assign, tag, comment, close cards"
  • Arguments: command:string (required), account_id:integer (optional)
  • Implementation:
    • Include Raix::ChatCompletion and Raix::FunctionDispatch
    • Define internal functions for operations:
      • create_card(board_id, title, description, tags, assignee_ids)
      • update_card(card_number, title, description, column_id)
      • move_card(card_number, column_id:, board_id:)
      • assign_card(card_number, assignee_ids)
      • close_card(card_number, reason:)
      • add_comment(card_number, body)
      • add_tags(card_number, tag_titles)
    • Use nested LLM to parse command → function call
    • Execute function in account context with authorization checks
    • Return success/error with entity details

4. Raix LLM Assistant

app/services/fizzy_assistant.rb

  • Include Raix::ChatCompletion, Raix::FunctionDispatch
  • Include FizzyToolFunctions module (defines function bridges)
  • System prompt: Describe Fizzy entities (cards, boards, users, tags, columns)
  • Explain 3 available functions (fizzy_search, fizzy_read, fizzy_execute)
  • Model: ENV.fetch("FIZZY_QUERY_MODEL", "google/gemini-3-flash-preview")
  • #ask(query, account:, identity:, &streamer) method
  • Store context (@context = {account, identity}) for function calls
  • Build transcript: system prompt + user query
  • Call chat_completion (Raix handles function dispatch)

app/services/concerns/fizzy_tool_functions.rb

  • Define Raix function declarations for each of the 3 tools:
    • function :fizzy_search with query, account_id, entity_type, limit params
    • function :fizzy_read with entity_type, identifier, account_id params
    • function :fizzy_execute with command, account_id params
  • Each function block:
    • Validates account access (identity has user in account)
    • Sets Current.account and Current.user via Current.with_account
    • Instantiates appropriate tool class
    • Calls tool.call(**args)
    • Extracts result from MCP format (unwrap JSON string)
    • Returns JSON string for LLM consumption
  • Private #call_tool(tool_class, **args) helper
  • Private #extract_tool_result(mcp_result) helper

5. Configuration

Gemfile (modify)

  • Add gem "fast_mcp"
  • Add gem "raix"
  • Run bundle install

config/initializers/raix.rb

  • Configure Raix with OpenRouter client (like Nexus)
  • Use ENV["OPENROUTER_API_KEY"]
  • Add retry logic and logging

Implementation Sequence

Phase 1: Foundation

  1. Add fast_mcp and raix to Gemfile
  2. Create and run AccessToken enhancement migration
  3. Modify app/models/identity/access_token.rb:
    • Add associations, callbacks, class methods
  4. Create lib/fizzy_mcp_transport.rb:
    • Token validation, account resolution, HTTP message handling
  5. Create config/initializers/fast_mcp.rb:
    • Server setup, middleware mounting
  6. Create config/initializers/raix.rb:
    • OpenRouter configuration
  7. Manual test: POST to /mcp with invalid token → expect 401

Phase 2: Base Tool Infrastructure

  1. Create app/tools/application_tool.rb:
    • Base class with json_result helper, context accessors
  2. Create simple ping tool for testing:
    • app/tools/fizzy_ping_tool.rb returns {status: "ok", account: Current.account.name}
  3. Test tool registration works via bin/rails runner "p ApplicationTool.descendants"
  4. Manual MCP test: Call ping tool, verify account context

Phase 3: Read-Only Tools

  1. Create app/tools/fizzy_search_tool.rb:
    • Use existing Search::Query class
    • Scope to Current.user.accessible_cards/boards/users
    • Return array of results with snippets
  2. Create app/tools/fizzy_read_tool.rb:
    • Case switch on entity_type
    • Use accessible_* associations for authorization
    • Return rich JSON (mirror JBuilder structure)
  3. Add system tests for both tools
  4. Manual test via Claude Code MCP client

Phase 4: LLM Assistant Integration

  1. Create app/services/concerns/fizzy_tool_functions.rb:
    • Define Raix function declarations for search/read
    • Implement call_tool and extract_tool_result helpers
  2. Create app/services/fizzy_assistant.rb:
    • System prompt, model config, ask method
  3. Test assistant can interpret queries and call tools
  4. Integration test: Ask "find my urgent cards" → expect search call

Phase 5: Execute Tool (Mutations)

  1. Extend app/tools/fizzy_execute_tool.rb:
    • Define internal functions (create_card, move_card, etc.)
    • Use nested Raix::FunctionDispatch for command parsing
    • Authorization checks via User#can_administer_card?
  2. Add function to fizzy_tool_functions.rb:
    • Define fizzy_execute function
  3. Comprehensive tests:
    • Create card with tags and assignees
    • Move card between columns/boards
    • Close card with reason comment
    • Assign/unassign users
  4. Test authorization: member can't delete board

Phase 6: Polish & Documentation

  1. Error handling: Wrap tool calls in rescue blocks
  2. Add HATEOAS hints to all responses (URLs for related resources)
  3. Performance review: Check for N+1 queries, add includes
  4. README section on MCP usage:
    • How to generate access token
    • How to configure Claude Code
    • Example queries
  5. API documentation for the 3 tools

Key Design Decisions (Based on User Input)

  1. Multi-Tenancy: Default account on access token with optional account_id override

    • Simplifies single-account use case
    • Still supports multi-account identities
    • Token creation UX must include account selection
  2. Permissions: Simple read/write model (no granular per-resource permissions)

    • Matches existing Identity::AccessToken design
    • Authorization enforced at runtime via User roles and accessible_* scopes
    • Write tokens can perform all mutations user is authorized for
  3. Search: Use existing MySQL full-text search (no semantic embeddings)

    • Leverages existing Search::Query with 16 shards
    • LLM interprets natural language → structured filters
    • No new dependencies or indexing pipeline needed
    • Can add semantic search in v2 if needed

Authorization Strategy

All operations respect Fizzy's existing authorization:

  • Search/read use Current.user.accessible_* associations (from User::Accessor)
  • Mutations check User#can_administer_card?, User#can_administer_board?
  • Board access via Access records or all_access flag
  • Return 403 (not 404) when user lacks access to existing resource
  • System role checks for dangerous operations (delete board, change settings)

Multi-Tenancy Implementation

  1. Token Creation: When creating access token, user selects default account
  2. Request Handling:
    # In FizzyMcpTransport
    account = if params[:account_id]
      # Override: use specific account if provided
      Account.find_by(external_account_id: params[:account_id])
    else
      # Default: use token's default account
      token.default_account
    end
    
    # Validate identity has access
    user = Current.identity.users.find_by(account: account)
    raise Unauthorized unless user
    
    # Set context
    Current.with_account(account) do
      Current.user = user
      # ... execute tool
    end
  3. Error Handling: Clear error if identity lacks user in requested account

Testing Strategy

Unit Tests:

  • test/models/identity/access_token_test.rb: Authentication, expiration
  • test/tools/fizzy_search_tool_test.rb: Search with various filters
  • test/tools/fizzy_read_tool_test.rb: Read each entity type
  • test/tools/fizzy_execute_tool_test.rb: Each mutation operation
  • test/services/fizzy_assistant_test.rb: Mock LLM, verify function dispatch

Integration Tests:

  • test/integration/mcp_authentication_test.rb: Token validation flow
  • test/integration/mcp_search_test.rb: End-to-end search via MCP
  • test/integration/mcp_mutations_test.rb: Create/update/move cards

System Tests (optional):

  • Use VCR to record LLM interactions
  • Test full assistant conversation flow

Mock Strategy:

  • Mock Raix::ChatCompletion in tests to avoid real API calls
  • Provide fixture function call responses
  • Test tool logic directly (bypass assistant layer)

Verification Plan (Manual Testing)

1. Setup

bin/rails db:migrate
bin/rails console
# Create access token with default account
identity = Identity.find_by(email: "david@example.com")
account = Account.first
token = identity.access_tokens.create!(
  permission: :write,
  name: "MCP Test Token",
  default_account: account,
  expires_at: 30.days.from_now
)
puts "Token: #{token.token}"

2. Test Authentication

curl -X POST http://fizzy.localhost:3006/mcp \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/list",
    "id": 1
  }'
# Expect: List of 3 tools (fizzy_search, fizzy_read, fizzy_execute)

3. Test Search Tool

curl -X POST http://fizzy.localhost:3006/mcp \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "fizzy_search",
      "arguments": {
        "query": "urgent bugs",
        "entity_type": "card",
        "limit": 10
      }
    },
    "id": 2
  }'
# Expect: Array of matching cards with snippets

4. Test Read Tool

curl -X POST http://fizzy.localhost:3006/mcp \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "fizzy_read",
      "arguments": {
        "entity_type": "card",
        "identifier": "1"
      }
    },
    "id": 3
  }'
# Expect: Full card details with comments, assignees, tags

5. Test Execute Tool (Create Card)

curl -X POST http://fizzy.localhost:3006/mcp \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "fizzy_execute",
      "arguments": {
        "command": "Create a card titled 'Test MCP Integration' on the first board"
      }
    },
    "id": 4
  }'
# Expect: Success with new card number

6. Test via Claude Code

  1. Configure Claude Code with MCP server:
    {
      "mcpServers": {
        "fizzy": {
          "url": "http://fizzy.localhost:3006/mcp",
          "transport": "http",
          "headers": {
            "Authorization": "Bearer TOKEN"
          }
        }
      }
    }
  2. Ask Claude: "What urgent cards are assigned to me in Fizzy?"
  3. Verify: Claude calls fizzy_search → returns results
  4. Ask Claude: "Create a card about fixing the login bug"
  5. Verify: Claude calls fizzy_execute → card created

7. Test Multi-Account Override

# Get second account ID (external_account_id)
# Use account_id parameter to override default
curl -X POST http://fizzy.localhost:3006/mcp \
  -H "Authorization: Bearer TOKEN" \
  -d '{
    "params": {
      "name": "fizzy_search",
      "arguments": {
        "query": "bugs",
        "account_id": 9876543
      }
    }
  }'
# Expect: Results from second account (if identity has access)

8. Test Authorization Failures

  • Try to read card from board without access → expect 403
  • Try to delete board as member (not admin) → expect 403
  • Try to use read token for mutations → expect 403

Files Modified

  • Gemfile - Add fast_mcp, raix gems
  • app/models/identity/access_token.rb - Add MCP enhancements

Files Created

  • db/migrate/xxx_enhance_identity_access_tokens_for_mcp.rb
  • lib/fizzy_mcp_transport.rb
  • config/initializers/fast_mcp.rb
  • config/initializers/raix.rb
  • app/tools/application_tool.rb
  • app/tools/fizzy_search_tool.rb
  • app/tools/fizzy_read_tool.rb
  • app/tools/fizzy_execute_tool.rb
  • app/services/fizzy_assistant.rb
  • app/services/concerns/fizzy_tool_functions.rb
  • Test files for all of the above

Open Questions / Future Enhancements

  1. Rate Limiting: Add Rack::Attack before public beta?
  2. Token Management UI: Build admin interface for creating/revoking MCP tokens?
  3. Audit Logging: Track all MCP operations in Event stream?
  4. Semantic Search: Add pgvector for fuzzy queries in v2?
  5. Streaming Responses: Support SSE for long-running operations?
  6. Webhook Integration: Allow MCP to register webhooks for real-time updates?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions