This specification outlines Model Context Protocol (MCP) integration for Ashfolio, enabling Claude Code and other MCP clients to interact with portfolio data through type-safe Ash actions.
- Expose Ash Actions as MCP Tools - Let Claude query portfolio, transactions, accounts
- Integrate with Module System - Enable dynamic tool discovery/loading
- Prepare for Advanced Tool Use - Document future code execution patterns
IMPORTANT: MCP tool results become part of the conversation context sent to the LLM provider.
| MCP Client | Tool Execution | Results Sent To | Privacy Level |
|---|---|---|---|
| Claude Code CLI | Local | Anthropic API | Medium |
| Claude.app | Local | Anthropic API | Medium |
| Local LLM (Ollama) | Local | Nowhere | Full |
Ashfolio implements a privacy mode setting to control data exposure:
# config/config.exs
config :ashfolio, :mcp,
privacy_mode: :anonymized # :strict | :anonymized | :standard | :full| Mode | Behavior | Use Case |
|---|---|---|
:strict |
Aggregates only, no structure | Maximum privacy, limited Claude utility |
:anonymized |
Relative data only - percentages, ratios, tiers | Cloud LLM with full analytical capability |
:standard |
Summaries with account names, limited history | Convenience over privacy |
:full |
Complete data access | Local LLM only (Ollama) |
The :anonymized mode enables Claude to perform full financial analysis without exposing sensitive data:
| Sensitive Data | Anonymized Form | Claude Can Still... |
|---|---|---|
| Account names | "Account A", "Account B" | Analyze allocation across accounts |
| Exact balances | Relative weights (35%, 25%) | Evaluate concentration risk |
| Net worth | Tier (:five_figures, :six_figures) | Provide tier-appropriate advice |
| Transaction amounts | Percentile buckets | Identify spending patterns |
| Stock symbols | Sector/asset class | Assess diversification |
| Dates | Relative ("3 months ago") | Analyze timing patterns |
| Ratios/percentages | Pass through unchanged | Full ratio analysis |
Raw data (never sent):
%{
accounts: [
%{name: "Fidelity 401k", balance: Decimal.new("125432.17"), holdings: ["VTI", "VXUS"]},
%{name: "Vanguard Roth IRA", balance: Decimal.new("45000.00"), holdings: ["VTI"]},
%{name: "Chase Checking", balance: Decimal.new("8500.00")}
]
}Anonymized (sent to Claude):
%{
accounts: [
%{id: "A", type: :retirement_401k, weight: 0.70, asset_classes: %{us_equity: 0.8, intl_equity: 0.2}},
%{id: "B", type: :retirement_ira, weight: 0.25, asset_classes: %{us_equity: 1.0}},
%{id: "C", type: :checking, weight: 0.05, asset_classes: %{cash: 1.0}}
],
portfolio: %{
value_tier: :six_figures, # $100k-$999k range
value_percentile: :p50_p75, # Relative to typical users
concentration: :high, # Top account is 70%
diversification_score: 0.65 # 0-1 scale
},
metrics: %{
# Ratios pass through - not sensitive
savings_rate: 0.22,
debt_to_income: 0.15,
emergency_fund_months: 4.5,
expense_ratio_weighted: 0.08
}
}- "Your portfolio is concentrated - 70% in one account"
- "Savings rate of 22% is above the recommended 20%"
- "4.5 months emergency fund is below the 6-month target"
- "Consider international diversification - currently only 14% of equity"
- "Your expense ratios are reasonable at 0.08% weighted average"
- Your actual net worth
- Which brokerages you use
- Specific stock/fund holdings
- Exact transaction amounts
- Account numbers or identifiers
# lib/ashfolio_web/mcp/privacy_filter.ex
defmodule AshfolioWeb.Mcp.PrivacyFilter do
@moduledoc """
Filters MCP tool results based on configured privacy mode.
Prevents accidental exposure of sensitive financial data to cloud LLMs.
"""
alias AshfolioWeb.Mcp.Anonymizer
def filter_result(result, tool_name, opts \\ []) do
mode = Application.get_env(:ashfolio, :mcp)[:privacy_mode] || :anonymized
case mode do
:strict -> apply_strict_filter(result, tool_name)
:anonymized -> Anonymizer.anonymize(result, tool_name)
:standard -> apply_standard_filter(result, tool_name)
:full -> result
end
end
# ... strict and standard implementations
end# lib/ashfolio_web/mcp/anonymizer.ex
defmodule AshfolioWeb.Mcp.Anonymizer do
@moduledoc """
Transforms financial data into anonymized form for cloud LLM analysis.
Preserves analytical value (ratios, percentages, patterns) while removing
sensitive identifiers (names, exact amounts, symbols).
"""
@value_tiers [
{:under_10k, 0, 10_000},
{:five_figures, 10_000, 100_000},
{:six_figures, 100_000, 1_000_000},
{:seven_figures, 1_000_000, 10_000_000},
{:eight_figures_plus, 10_000_000, :infinity}
]
@doc """
Anonymize account data for MCP response.
"""
def anonymize(accounts, :list_accounts) when is_list(accounts) do
total = accounts |> Enum.map(& &1.balance) |> Enum.reduce(Decimal.new(0), &Decimal.add/2)
%{
accounts: accounts |> Enum.with_index() |> Enum.map(fn {acc, idx} ->
%{
id: account_id(idx),
type: anonymize_account_type(acc.type, acc.name),
weight: calculate_weight(acc.balance, total),
asset_classes: anonymize_holdings(acc)
}
end),
portfolio: %{
value_tier: value_to_tier(total),
account_count: length(accounts),
concentration: concentration_level(accounts, total),
diversification_score: calculate_diversification(accounts)
}
}
end
def anonymize(transactions, :list_transactions) when is_list(transactions) do
%{
summary: %{
count: length(transactions),
date_range: relative_date_range(transactions),
by_type: transactions |> Enum.group_by(& &1.type) |> Map.new(fn {k, v} -> {k, length(v)} end)
},
patterns: %{
avg_transaction_tier: avg_amount_tier(transactions),
frequency: transaction_frequency(transactions),
categories: category_breakdown(transactions)
}
}
end
def anonymize(result, :get_portfolio_summary) do
%{
value_tier: value_to_tier(result.total_value),
allocation: result.allocation, # Percentages pass through
metrics: %{
ytd_return_pct: result.ytd_return,
diversification_score: result.diversification,
risk_level: result.risk_level
},
# Ratios are not sensitive - pass through
ratios: Map.take(result, [:savings_rate, :debt_to_income, :expense_ratio])
}
end
# Helper functions
defp account_id(index), do: <<?A + index>> # "A", "B", "C", ...
defp anonymize_account_type(type, name) do
cond do
String.contains?(String.downcase(name || ""), "401k") -> :retirement_401k
String.contains?(String.downcase(name || ""), "ira") -> :retirement_ira
type in [:brokerage, :investment] -> :taxable_investment
type == :checking -> :checking
type == :savings -> :savings
true -> :other
end
end
defp value_to_tier(amount) do
amount_float = Decimal.to_float(amount)
Enum.find_value(@value_tiers, :unknown, fn {tier, min, max} ->
max_val = if max == :infinity, do: :infinity, else: max
if amount_float >= min && (max_val == :infinity || amount_float < max_val), do: tier
end)
end
defp calculate_weight(balance, total) do
if Decimal.compare(total, 0) == :gt do
balance |> Decimal.div(total) |> Decimal.round(2) |> Decimal.to_float()
else
0.0
end
end
defp concentration_level(accounts, total) do
max_weight = accounts
|> Enum.map(&calculate_weight(&1.balance, total))
|> Enum.max(fn -> 0 end)
cond do
max_weight > 0.7 -> :very_high
max_weight > 0.5 -> :high
max_weight > 0.3 -> :moderate
true -> :well_distributed
end
end
defp relative_date_range(transactions) do
dates = Enum.map(transactions, & &1.date)
min_date = Enum.min(dates, Date)
max_date = Enum.max(dates, Date)
days_span = Date.diff(max_date, min_date)
%{
span_days: days_span,
oldest_relative: days_ago(min_date),
newest_relative: days_ago(max_date)
}
end
defp days_ago(date) do
diff = Date.diff(Date.utc_today(), date)
cond do
diff == 0 -> "today"
diff == 1 -> "yesterday"
diff < 7 -> "#{diff} days ago"
diff < 30 -> "#{div(diff, 7)} weeks ago"
diff < 365 -> "#{div(diff, 30)} months ago"
true -> "#{div(diff, 365)} years ago"
end
end
# Additional helper functions...
endWhen MCP is enabled with a cloud LLM provider, display a warning:
┌─────────────────────────────────────────────────────────────┐
│ ⚠️ MCP Privacy Notice │
│ │
│ MCP tool results are sent to your LLM provider (Anthropic) │
│ as part of the conversation. │
│ │
│ Current mode: Standard │
│ [Change to Strict] [Use Local LLM] [Dismiss] │
└─────────────────────────────────────────────────────────────┘
Why MCP over API Integration?
| Approach | Cost | Privacy | Complexity |
|---|---|---|---|
| Embed API calls in app | Pay-per-token ($5-25/M) | Data leaves device | Medium |
| MCP via subscription | Included in Claude Pro/Max | Data stays local* | Low |
*Tool execution is local; results sent to LLM as conversation context.
For users with existing Claude subscriptions, MCP is effectively zero marginal cost.
lib/ashfolio/ai/
├── dispatcher.ex # Routes to handlers
├── handler.ex # Handler behaviour
├── model.ex # LLM provider selection
└── handlers/
└── transaction_parser.ex
lib/ashfolio_web/
├── router.ex # Add MCP forward
└── mcp/
├── tool_definitions.ex # Tool metadata & examples
└── module_registry.ex # Dynamic tool loading
# Phoenix Router addition:
forward "/mcp", AshAi.Mcp.Router,
otp_app: :ashfolio,
tools: [...],
mcp_name: "Ashfolio Portfolio Manager"
Expose these Ash actions as MCP tools:
| Tool Name | Resource | Action | Description |
|---|---|---|---|
list_accounts |
Account | :read | List all portfolio accounts |
get_account |
Account | :read | Get account by ID with holdings |
list_transactions |
Transaction | :read | Query transactions with filters |
get_portfolio_summary |
(custom) | :action | Overall portfolio metrics |
list_symbols |
Symbol | :read | Available securities |
| Tool Name | Resource | Action | Description |
|---|---|---|---|
calculate_tax_lots |
(custom) | :action | FIFO cost basis analysis |
analyze_performance |
(custom) | :action | TWR/MWR calculations |
calculate_risk_metrics |
(custom) | :action | Volatility, Sharpe, etc. |
run_retirement_forecast |
(custom) | :action | Monte Carlo projections |
calculate_money_ratios |
(custom) | :action | Financial health assessment |
Add tool declarations to domains:
# lib/ashfolio/portfolio.ex
defmodule Ashfolio.Portfolio do
use Ash.Domain,
extensions: [AshAi]
tools do
tool :list_accounts, Ashfolio.Portfolio.Account, :read,
description: "List all investment and cash accounts",
load: [:current_value, :holdings]
tool :list_transactions, Ashfolio.Portfolio.Transaction, :read,
description: "Query transactions by account, date, type, or symbol",
action_parameters: [:filter, :sort, :limit]
tool :get_portfolio_summary, Ashfolio.Portfolio.PortfolioSummary, :summary,
description: "Get aggregate portfolio value, allocation, and performance"
end
endProvide 1-5 examples per tool to improve accuracy:
# lib/ashfolio_web/mcp/tool_definitions.ex
defmodule AshfolioWeb.Mcp.ToolDefinitions do
@moduledoc """
Tool definitions with examples for MCP clients.
Following Anthropic's advanced tool use patterns.
"""
def tool_examples do
%{
"list_transactions" => [
# Minimal
%{
input: %{},
description: "List recent transactions"
},
# With filter
%{
input: %{filter: %{type: "buy"}, limit: 10},
description: "List last 10 buy transactions"
},
# Full specification
%{
input: %{
filter: %{
account_id: "uuid-here",
date: %{gte: "2024-01-01", lte: "2024-12-31"}
},
sort: [%{field: "date", direction: "desc"}],
limit: 50
},
description: "Get 2024 transactions for specific account"
}
],
"calculate_tax_lots" => [
%{
input: %{symbol: "AAPL", tax_year: 2024},
description: "Calculate AAPL cost basis for 2024 taxes"
}
]
}
end
end# lib/ashfolio_web/router.ex
defmodule AshfolioWeb.Router do
# ... existing routes ...
# MCP endpoint for Claude Code / Claude.app integration
forward "/mcp", AshAi.Mcp.Router,
otp_app: :ashfolio,
mcp_name: "Ashfolio Portfolio Manager",
mcp_server_version: "0.8.0"
endConnect MCP to the Smart Parsing Module System:
# lib/ashfolio_web/mcp/module_registry.ex
defmodule AshfolioWeb.Mcp.ModuleRegistry do
@moduledoc """
Manages dynamic MCP tool registration based on installed modules.
"""
@doc """
Get tools based on installed/enabled modules.
Implements deferred loading pattern from Anthropic's advanced tool use.
"""
def get_tools(opts \\ []) do
core_tools = get_core_tools()
module_tools = get_module_tools(opts)
# Deferred tools marked for on-demand loading
deferred = Keyword.get(opts, :include_deferred, false)
if deferred do
core_tools ++ module_tools ++ get_deferred_tools()
else
core_tools ++ module_tools
end
end
@doc """
Register a parsing module as an MCP tool.
"""
def register_module(module, opts) do
%{
name: module.tool_name(),
description: module.tool_description(),
parameters_schema: module.parameters_schema(),
function: &module.execute/2,
metadata: %{
defer_loading: Keyword.get(opts, :defer_loading, false),
module_type: :parsing
}
}
end
endExtend the Parseable behaviour to support MCP:
# lib/ashfolio/parsing/behaviours/parseable.ex
defmodule Ashfolio.Parsing.Parseable do
@moduledoc "Behaviour for parsing modules with MCP support"
# Existing callbacks
@callback can_parse?(text :: String.t()) :: boolean()
@callback parse(text :: String.t()) :: {:ok, result()} | {:error, reason()}
@callback confidence() :: :high | :medium | :low
# MCP integration callbacks (optional)
@callback tool_name() :: String.t()
@callback tool_description() :: String.t()
@callback parameters_schema() :: map()
@callback mcp_enabled?() :: boolean()
@optional_callbacks [tool_name: 0, tool_description: 0, parameters_schema: 0, mcp_enabled?: 0]
end# lib/ashfolio/parsing/modules/expense_parser.ex
defmodule Ashfolio.Parsing.Modules.ExpenseParser do
@behaviour Ashfolio.Parsing.Parseable
# Parsing implementation...
# MCP Tool Interface
def tool_name, do: "parse_expenses"
def tool_description do
"""
Parse natural language expense descriptions into structured data.
Examples: "Netflix, Spotify, $1800 rent" or "I spend $500 on groceries"
Returns parsed expenses with amounts, categories, and confidence scores.
"""
end
def parameters_schema do
%{
type: :object,
properties: %{
text: %{
type: :string,
description: "Natural language expense description"
},
include_suggestions: %{
type: :boolean,
default: true,
description: "Include category suggestions for ambiguous items"
}
},
required: ["text"]
}
end
def mcp_enabled?, do: true
endImplement Anthropic's "Tool Search Tool" pattern for large tool sets:
# lib/ashfolio_web/mcp/tool_search.ex
defmodule AshfolioWeb.Mcp.ToolSearch do
@moduledoc """
Implements deferred tool loading via search.
Reduces token usage by ~85% for large tool sets.
"""
@doc """
Search for tools by keyword/description.
Claude calls this to discover available tools on-demand.
"""
def search(query, opts \\ []) do
detail_level = Keyword.get(opts, :detail, :description)
all_tools()
|> Enum.filter(&matches?(&1, query))
|> Enum.map(&format_result(&1, detail_level))
end
defp matches?(tool, query) do
query = String.downcase(query)
String.contains?(String.downcase(tool.name), query) ||
String.contains?(String.downcase(tool.description), query) ||
Enum.any?(tool.keywords || [], &String.contains?(String.downcase(&1), query))
end
defp format_result(tool, :name_only), do: tool.name
defp format_result(tool, :description), do: %{name: tool.name, description: tool.description}
defp format_result(tool, :full), do: tool
endOrganize tools for efficient discovery:
@tool_categories %{
"portfolio" => [
:list_accounts, :get_account, :get_portfolio_summary
],
"transactions" => [
:list_transactions, :create_transaction, :parse_transaction
],
"analytics" => [
:calculate_performance, :calculate_risk_metrics, :run_efficient_frontier
],
"tax" => [
:calculate_tax_lots, :analyze_wash_sales, :estimate_capital_gains
],
"planning" => [
:run_retirement_forecast, :calculate_money_ratios, :analyze_expenses
],
"parsing" => [
:parse_expenses, :parse_income, :parse_account
]
}Before implementing code execution:
- Sandbox Environment - Isolated execution context
- Resource Limits - CPU, memory, time bounds
- Access Controls - Which tools accessible from code
- Audit Logging - Track executed code for security
┌─────────────────────────────────────────────────────────┐
│ Claude Code CLI │
└─────────────────────┬───────────────────────────────────┘
│ MCP Protocol
▼
┌─────────────────────────────────────────────────────────┐
│ Ashfolio MCP Server │
├─────────────────────┬───────────────────────────────────┤
│ Direct Tools │ Code Execution (Future) │
│ - list_accounts │ - sandbox environment │
│ - list_txns │ - tool wrappers as TS/Python │
│ - parse_expense │ - result filtering │
│ ... │ - skill persistence │
└─────────────────────┴───────────────────────────────────┘
Per Anthropic's engineering blog:
- 98.7% token reduction for complex multi-tool operations
- Data filtering - Process 10K transactions, return 50 relevant
- Privacy preservation - Intermediate data stays in sandbox
- Skill development - Save working code as reusable functions
| Feature | Status | Notes |
|---|---|---|
| Tool Search Tool | Future | >10K tokens in definitions |
| Programmatic Tool Calling | Future | Requires sandbox |
| Skill Persistence | Future | Save working code |
| PII Auto-tokenization | Future | Privacy enhancement |
- Add
AshAi.Mcp.Routerto Phoenix router - Define tools in Portfolio domain
- Define tools in FinancialManagement domain
- Add tool use examples for each tool
- Test with Claude Code CLI locally
- Document Claude Code configuration
- Extend Parseable behaviour with MCP callbacks
- Implement ModuleRegistry for dynamic tools
- Connect parsing modules to MCP
- Add tool search endpoint
- Implement deferred loading
- Design sandbox architecture
- Implement code execution tool
- Add skill persistence
- Build audit logging
- Performance optimization
When users enable cloud LLM features (:standard or :full privacy modes), Ashfolio must disclose:
-
Data Transmission Notice
- Financial data will be transmitted to third-party AI providers
- Specify which provider (Anthropic, OpenAI, etc.)
- Link to provider's privacy policy and data retention terms
-
Data Usage Clarity
- What data is sent (tool results as conversation context)
- How data may be used by the provider (training, storage, etc.)
- User's rights regarding their data
-
Anonymization Limitations
- Even anonymized data reveals financial patterns
- Aggregated data may still be personally identifiable in context
- No guarantee of complete privacy with cloud providers
## Third-Party AI Services Disclosure
Ashfolio optionally integrates with third-party AI services for enhanced
financial analysis. When enabled, the following applies:
### Data Transmission
- Financial data (filtered by your privacy mode setting) is transmitted
to your configured AI provider as part of conversation context
- Default mode (:anonymized) sends only relative percentages, ratios,
and categorical data - not actual account names or dollar amounts
- Full mode sends complete financial data to the AI provider
### Supported Providers & Their Terms
- **Anthropic (Claude)**: [Privacy Policy](https://www.anthropic.com/privacy)
- **OpenAI**: [Privacy Policy](https://openai.com/privacy)
- **Ollama (Local)**: No data transmission - runs entirely on your device
### Your Choices
- Use :strict or :anonymized mode to limit data exposure
- Use Ollama for complete local processing with no cloud transmission
- Disable MCP integration entirely to prevent any AI data access
### No Warranty
Ashfolio makes no representations about third-party AI providers' data
handling practices. Users are responsible for reviewing provider terms
before enabling cloud AI features.
By enabling cloud AI features, you acknowledge that financial data
(as filtered by your privacy setting) will be transmitted to your
selected AI provider.First-time MCP setup with cloud provider:
┌─────────────────────────────────────────────────────────────────┐
│ Enable Claude Integration │
│ │
│ This will allow Claude to analyze your portfolio data. │
│ │
│ ⚠️ Data Disclosure │
│ │
│ Your financial data (filtered by privacy mode) will be sent │
│ to Anthropic as part of your conversation with Claude. │
│ │
│ Privacy Mode: [Anonymized ▼] │
│ │
│ • Anonymized (recommended): Percentages and ratios only │
│ • Standard: Account names and summaries │
│ • Full: Complete data (use with Ollama only) │
│ │
│ 📄 View Anthropic's Privacy Policy │
│ 📄 View full data disclosure terms │
│ │
│ ☐ I understand that my financial data will be transmitted │
│ to Anthropic when using Claude integration │
│ │
│ [Cancel] [Enable Integration] │
└─────────────────────────────────────────────────────────────────┘
# Store user consent in database
defmodule Ashfolio.Settings.AiConsent do
use Ash.Resource
attributes do
uuid_primary_key :id
attribute :provider, :atom # :anthropic, :openai, :ollama
attribute :privacy_mode, :atom # :strict, :anonymized, :standard, :full
attribute :consented_at, :utc_datetime
attribute :consent_version, :string # Track terms version
attribute :ip_hash, :string # For audit trail (hashed)
end
end| Item | Status | Notes |
|---|---|---|
| GDPR compliance | Future | Data portability, right to deletion |
| CCPA compliance | Future | California privacy requirements |
| Financial data regulations | Research | May vary by jurisdiction |
| Provider agreement updates | Ongoing | Monitor for terms changes |
Before v1.0 release with MCP features:
- Legal review of disclosure language
- Confirm compliance with Anthropic/OpenAI terms of service
- Determine if financial data has special regulatory status
- Establish process for updating disclosures when provider terms change
Tool results are sent to the LLM provider as conversation context.
Mitigations:
- Privacy mode setting:
:strict,:anonymized,:standard, or:full - Default to
:anonymized: Full analytical capability without exposing sensitive data - Anonymizer transforms: Converts amounts to percentages, names to IDs, values to tiers
- Require
:fullmode opt-in: User must explicitly enable for complete data access - UI warning: Display notice when cloud LLM is configured
- Local LLM recommendation: Suggest Ollama for maximum privacy
- Local-first: MCP server only accessible locally by default
- No authentication required: Single-user, database-as-user model
- Read-heavy: Most operations are queries, not mutations
- Audit trail: Log all tool invocations
- Privacy filter: All results pass through
PrivacyFilterbefore returning
When implemented:
- Sandboxed execution environment (WASM, Docker, or similar)
- Time limits on execution (30s max)
- Memory limits (256MB max)
- No filesystem access outside sandbox
- No network access from sandbox
- Allowlist of callable tools
# test/ashfolio_web/mcp/tool_definitions_test.exs
describe "tool examples" do
test "all tools have at least one example" do
for {tool_name, examples} <- ToolDefinitions.tool_examples() do
assert length(examples) >= 1, "#{tool_name} needs examples"
end
end
test "examples include minimal, partial, and full patterns" do
examples = ToolDefinitions.tool_examples()["list_transactions"]
assert Enum.any?(examples, &(map_size(&1.input) == 0)) # minimal
assert Enum.any?(examples, &(map_size(&1.input) > 2)) # full
end
end# test/ashfolio_web/mcp/server_test.exs
describe "MCP server" do
test "initializes with correct capabilities" do
{:ok, response} = send_mcp_request("initialize", %{})
assert response["capabilities"]["tools"]
end
test "lists available tools" do
{:ok, response} = send_mcp_request("tools/list", %{})
tool_names = Enum.map(response["tools"], & &1["name"])
assert "list_accounts" in tool_names
assert "list_transactions" in tool_names
end
test "executes tool and returns results" do
{:ok, response} = send_mcp_request("tools/call", %{
"name" => "list_accounts",
"arguments" => %{}
})
assert response["result"]["content"]
end
end- Anthropic: Advanced Tool Use
- Anthropic: Code Execution with MCP
- MCP Specification
- Ash AI Documentation
Last Updated: November 2025 | Target: v0.9.0 - v1.0+