Ledger CLI MCP Server
by minhyeoky
Verified
import os
import subprocess
from typing import List, Optional
from pydantic import BaseModel, Field
from mcp.server.fastmcp import FastMCP
# Environment variable for ledger file path with default
# First check command-line argument, then environment variable
LEDGER_FILE = os.getenv("LEDGER_FILE")
# Initialize MCP server
mcp = FastMCP("Ledger CLI")
# Pydantic models for ledger commands
class LedgerBalance(BaseModel):
query: Optional[str] = Field(None, description="Filter accounts by regex pattern")
begin_date: Optional[str] = Field(
None, description="Start date for transactions (YYYY/MM/DD)"
)
end_date: Optional[str] = Field(
None, description="End date for transactions (YYYY/MM/DD)"
)
depth: Optional[int] = Field(None, description="Limit account depth displayed")
monthly: bool = Field(False, description="Group by month")
weekly: bool = Field(False, description="Group by week")
daily: bool = Field(False, description="Group by day")
yearly: bool = Field(False, description="Group by year")
flat: bool = Field(False, description="Show full account names without indentation")
no_total: bool = Field(False, description="Don't show the final total")
class LedgerRegister(BaseModel):
query: Optional[str] = Field(
None, description="Filter transactions by regex pattern"
)
begin_date: Optional[str] = Field(
None, description="Start date for transactions (YYYY/MM/DD)"
)
end_date: Optional[str] = Field(
None, description="End date for transactions (YYYY/MM/DD)"
)
monthly: bool = Field(False, description="Group by month")
weekly: bool = Field(False, description="Group by week")
daily: bool = Field(False, description="Group by day")
yearly: bool = Field(False, description="Group by year")
sort: Optional[str] = Field(
None, description="Sort transactions (date, amount, payee)"
)
by_payee: bool = Field(False, description="Group by payee")
current: bool = Field(
False, description="Show only transactions on or before today"
)
class LedgerAccounts(BaseModel):
query: Optional[str] = Field(None, description="Filter accounts by regex pattern")
class LedgerPayees(BaseModel):
query: Optional[str] = Field(None, description="Filter payees by regex pattern")
class LedgerCommodities(BaseModel):
query: Optional[str] = Field(
None, description="Filter commodities by regex pattern"
)
class LedgerPrint(BaseModel):
query: Optional[str] = Field(
None, description="Filter transactions by regex pattern"
)
begin_date: Optional[str] = Field(
None, description="Start date for transactions (YYYY/MM/DD)"
)
end_date: Optional[str] = Field(
None, description="End date for transactions (YYYY/MM/DD)"
)
class LedgerStats(BaseModel):
query: Optional[str] = Field(None, description="Filter for statistics")
class LedgerBudget(BaseModel):
query: Optional[str] = Field(None, description="Filter accounts by regex pattern")
begin_date: Optional[str] = Field(
None, description="Start date for transactions (YYYY/MM/DD)"
)
end_date: Optional[str] = Field(
None, description="End date for transactions (YYYY/MM/DD)"
)
monthly: bool = Field(False, description="Group by month")
weekly: bool = Field(False, description="Group by week")
daily: bool = Field(False, description="Group by day")
yearly: bool = Field(False, description="Group by year")
class LedgerRawCommand(BaseModel):
command: List[str] = Field(..., description="Raw ledger command arguments")
# Helper function to run ledger commands
def run_ledger(args: List[str]) -> str:
try:
if not LEDGER_FILE:
return "Ledger file path not set. Please provide it via --ledger-file argument or LEDGER_FILE environment variable."
# Validate inputs to prevent command injection
for arg in args:
if ";" in arg or "&" in arg or "|" in arg:
return "Error: Invalid characters in command arguments."
result = subprocess.run(
["ledger", "-f", LEDGER_FILE] + args,
check=True,
text=True,
capture_output=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
error_message = f"Ledger command failed: {e.stderr}"
if "couldn't find file" in e.stderr:
error_message = f"Ledger file not found at {LEDGER_FILE}. Please provide a valid path via --ledger-file argument or LEDGER_FILE environment variable."
return error_message
# Define MCP tools
@mcp.tool(description="Show account balances")
def ledger_balance(params: LedgerBalance) -> str:
cmd = ["balance"]
if params.query:
cmd.append(params.query)
if params.begin_date:
cmd.extend(["-b", params.begin_date])
if params.end_date:
cmd.extend(["-e", params.end_date])
if params.depth is not None:
cmd.extend(["--depth", str(params.depth)])
if params.monthly:
cmd.append("--monthly")
if params.weekly:
cmd.append("--weekly")
if params.daily:
cmd.append("--daily")
if params.yearly:
cmd.append("--yearly")
if params.flat:
cmd.append("--flat")
if params.no_total:
cmd.append("--no-total")
return run_ledger(cmd)
@mcp.tool(description="Show transaction register")
def ledger_register(params: LedgerRegister) -> str:
cmd = ["register"]
if params.query:
cmd.append(params.query)
if params.begin_date:
cmd.extend(["-b", params.begin_date])
if params.end_date:
cmd.extend(["-e", params.end_date])
if params.monthly:
cmd.append("--monthly")
if params.weekly:
cmd.append("--weekly")
if params.daily:
cmd.append("--daily")
if params.yearly:
cmd.append("--yearly")
if params.sort:
cmd.extend(["-S", params.sort])
if params.by_payee:
cmd.append("-P")
if params.current:
cmd.append("-c")
return run_ledger(cmd)
@mcp.tool(description="List all accounts")
def ledger_accounts(params: LedgerAccounts) -> str:
cmd = ["accounts"]
if params.query:
cmd.append(params.query)
return run_ledger(cmd)
@mcp.tool(description="List all payees")
def ledger_payees(params: LedgerPayees) -> str:
cmd = ["payees"]
if params.query:
cmd.append(params.query)
return run_ledger(cmd)
@mcp.tool(description="List all commodities")
def ledger_commodities(params: LedgerCommodities) -> str:
cmd = ["commodities"]
if params.query:
cmd.append(params.query)
return run_ledger(cmd)
@mcp.tool(description="Print transactions in ledger format")
def ledger_print(params: LedgerPrint) -> str:
cmd = ["print"]
if params.query:
cmd.append(params.query)
if params.begin_date:
cmd.extend(["-b", params.begin_date])
if params.end_date:
cmd.extend(["-e", params.end_date])
return run_ledger(cmd)
@mcp.tool(description="Show statistics about the ledger file")
def ledger_stats(params: LedgerStats) -> str:
cmd = ["stats"]
if params.query:
cmd.append(params.query)
return run_ledger(cmd)
@mcp.tool(description="Show budget report")
def ledger_budget(params: LedgerBudget) -> str:
cmd = ["budget"]
if params.query:
cmd.append(params.query)
if params.begin_date:
cmd.extend(["-b", params.begin_date])
if params.end_date:
cmd.extend(["-e", params.end_date])
if params.monthly:
cmd.append("--monthly")
if params.weekly:
cmd.append("--weekly")
if params.daily:
cmd.append("--daily")
if params.yearly:
cmd.append("--yearly")
return run_ledger(cmd)
@mcp.tool(description="Run a raw ledger command")
def ledger_raw_command(params: LedgerRawCommand) -> str:
return run_ledger(params.command)
@mcp.resource("ledger://file")
def get_ledger_file() -> str:
"""Return the path to the current ledger file."""
return LEDGER_FILE or ""