Add MCP HTTP endpoint with intelligent LLM-powered tools
- Dominant language
- Ruby
- Stars
- 8.2k
- Forks
- 1.2k
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 62
Description
# 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_id` → `accounts.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**:
```ruby
# 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
```bash
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
```bash
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
```bash
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
```bash
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)
```bash
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:
```json
{
"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
```bash
# 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?
Contributor guide
Assessment
This issue has not been assessed yet.