Skip to content

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

@obie

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions