mcp-ynab
Version:
Model Context Protocol server for YNAB integration
470 lines (405 loc) • 14.8 kB
JavaScript
// Load environment variables FIRST before any other imports
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = resolve(__dirname, '../.env');
dotenv.config({ path: envPath });
// Now import everything else after environment is loaded
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { ErrorHandler } from './shared/error-handler.js';
import PromptLoader from './shared/prompt-loader.js';
import PromptExecutor from './shared/prompt-executor.js';
import logger from './shared/logger.js';
// Import all tools
// Budget Overview Tools
import * as getBudgetSummary from './tools/budget-overview/get-budget-summary.js';
import * as getBudgetMonths from './tools/budget-overview/get-budget-months.js';
import * as getCategories from './tools/budget-overview/get-categories.js';
import * as listBudgets from './tools/budget-overview/list-budgets.js';
// Account Tools
import * as getAccounts from './tools/account-tools/get-accounts.js';
import * as getAccountDetails from './tools/account-tools/get-account-details.js';
// Transaction Analysis Tools
import * as getTransactions from './tools/transaction-analysis/get-transactions.js';
import * as searchTransactions from './tools/transaction-analysis/search-transactions.js';
import * as getPayees from './tools/transaction-analysis/get-payees.js';
import * as getScheduledTransactions from './tools/transaction-analysis/get-scheduled-transactions.js';
// Analysis & Reporting Tools
import * as getSpendingReport from './tools/analysis-reporting/get-spending-report.js';
import * as getCashFlowAnalysis from './tools/analysis-reporting/get-cash-flow-analysis.js';
import * as getGoalsStatus from './tools/analysis-reporting/get-goals-status.js';
import * as getNetWorthTrend from './tools/analysis-reporting/get-net-worth-trend.js';
import * as getOverspendingAnalysis from './tools/analysis-reporting/get-overspending-analysis.js';
import * as getUnderspendingAnalysis from './tools/analysis-reporting/get-underspending-analysis.js';
// Prompt Generator Tool
import * as promptGenerator from './tools/prompt-generator.js';
// Cache Management Tool
import * as showCacheStats from './tools/show-cache-stats.js';
// Parse command line arguments
const argv = yargs(hideBin(process.argv))
.option('prompts', {
alias: 'p',
type: 'string',
description: 'Path to custom YAML prompt file'
})
.option('list-prompts', {
type: 'boolean',
description: 'List available prompts and exit'
})
.help()
.argv;
class YNABMCPServer {
constructor() {
this.errorHandler = new ErrorHandler();
this.promptLoader = new PromptLoader();
this.promptExecutor = new PromptExecutor();
this.promptConfig = null;
// Log environment loading
logger.startup('Environment loaded', {
envPath,
hasApiKey: !!process.env.YNAB_API_KEY,
apiKeyLength: process.env.YNAB_API_KEY?.length,
nodeEnv: process.env.NODE_ENV,
mcpDebug: process.env.MCP_DEBUG,
loggerEnabled: logger.enabled
});
// Validate environment on startup
try {
this.errorHandler.validateEnvironment();
} catch (error) {
logger.error('Environment validation failed', { error: error.message });
process.exit(1);
}
this.server = new Server(
{
name: 'mcp-ynab',
version: '1.2.0',
},
{
capabilities: {
resources: {},
tools: {},
prompts: {},
},
}
);
// Register all tools
this.tools = new Map([
// Budget Overview Tools (4/4)
['get_budget_summary', getBudgetSummary],
['get_budget_months', getBudgetMonths],
['get_categories', getCategories],
['list_budgets', listBudgets],
// Account Tools (2/2)
['get_accounts', getAccounts],
['get_account_details', getAccountDetails],
// Transaction Analysis Tools (4/4)
['get_transactions', getTransactions],
['search_transactions', searchTransactions],
['get_payees', getPayees],
['get_scheduled_transactions', getScheduledTransactions],
// Analysis & Reporting Tools (6/6)
['get_spending_report', getSpendingReport],
['get_cash_flow_analysis', getCashFlowAnalysis],
['get_goals_status', getGoalsStatus],
['get_net_worth_trend', getNetWorthTrend],
['get_overspending_analysis', getOverspendingAnalysis],
['get_underspending_analysis', getUnderspendingAnalysis],
// Prompt Generator Tool
['generate_prompt', promptGenerator],
// Cache Management Tool
['show_cache_stats', showCacheStats],
]);
// Initialize prompts
this.prompts = new Map();
// Note: prompts will be loaded asynchronously in run()
this.setupHandlers();
}
/**
* Load prompts from YAML file (custom or default)
* Note: Prompts must have simple string steps for server compatibility
* Complex logic should be implemented as dedicated MCP tools
*/
async loadPrompts() {
try {
// Load prompt configuration
if (argv.prompts) {
logger.debug('Loading custom prompts', { file: argv.prompts });
this.promptConfig = await this.promptLoader.loadPromptFile(argv.prompts);
} else {
logger.debug('Loading default prompts');
this.promptConfig = await this.promptLoader.loadDefaultPrompts();
}
// Register prompts as MCP tools
for (const prompt of this.promptConfig.prompts) {
const promptDefinition = {
name: prompt.name,
description: prompt.description,
inputSchema: this.createPromptInputSchema(prompt)
};
const promptHandler = async (params) => {
// Validate parameters
const validation = this.promptLoader.validatePromptParameters(prompt, params);
if (!validation.valid) {
throw new Error(`Parameter validation failed: ${validation.errors.join(', ')}`);
}
// Execute the prompt
return await this.promptExecutor.executePrompt(prompt, validation.processedParameters);
};
this.prompts.set(prompt.name, {
promptDefinition,
handler: promptHandler,
config: prompt
});
}
logger.info('Prompts loaded successfully', {
count: this.promptConfig.prompts.length,
source: this.promptConfig.name
});
} catch (error) {
logger.error('Failed to load prompts', { error: error.message });
}
}
/**
* Create JSON Schema for prompt parameters
*/
createPromptInputSchema(prompt) {
const schema = {
type: 'object',
properties: {},
required: []
};
if (prompt.parameters) {
for (const param of prompt.parameters) {
schema.properties[param.name] = {
type: param.type,
description: param.description
};
if (param.enum) {
schema.properties[param.name].enum = param.enum;
}
if (param.minimum !== undefined) {
schema.properties[param.name].minimum = param.minimum;
}
if (param.maximum !== undefined) {
schema.properties[param.name].maximum = param.maximum;
}
if (param.default !== undefined) {
schema.properties[param.name].default = param.default;
}
if (param.required) {
schema.required.push(param.name);
}
}
}
return schema;
}
setupHandlers() {
// List available tools (including prompts as tools)
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = Array.from(this.tools.values()).map(tool => tool.toolDefinition);
// Add prompts as tools
const promptTools = Array.from(this.prompts.values()).map(prompt => prompt.promptDefinition);
return { tools: [...tools, ...promptTools] };
});
// Handle tool calls (including prompts)
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// Check if it's a regular tool
if (this.tools.has(name)) {
try {
const tool = this.tools.get(name);
const result = await tool.handler(args || {});
// Log tool execution
logger.tool(name, args, result);
// Check if result is an error
if (result && result.error) {
throw new Error(result.message || 'Tool execution failed');
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
logger.error('Tool execution failed', { tool: name, error: error.message });
const formattedError = this.errorHandler.formatForMCP(error);
return {
content: [
{
type: 'text',
text: JSON.stringify(formattedError, null, 2),
},
],
isError: true,
};
}
}
// Check if it's a prompt
if (this.prompts.has(name)) {
try {
const prompt = this.prompts.get(name);
const result = await prompt.handler(args || {});
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const formattedError = this.errorHandler.formatForMCP(error);
return {
content: [
{
type: 'text',
text: JSON.stringify(formattedError, null, 2),
},
],
isError: true,
};
}
}
throw new Error(`Unknown tool or prompt: ${name}`);
});
// List available prompts
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
const prompts = Array.from(this.prompts.values()).map(prompt => prompt.promptDefinition);
return { prompts };
});
// Handle prompt requests
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (!this.prompts.has(name)) {
throw new Error(`Unknown prompt: ${name}`);
}
try {
const prompt = this.prompts.get(name);
const result = await prompt.handler(args || {});
// Format response according to MCP GetPrompt protocol
return {
messages: [
{
role: "assistant",
content: {
type: "text",
text: JSON.stringify(result, null, 2)
}
}
]
};
} catch (error) {
const formattedError = this.errorHandler.formatForMCP(error);
return {
messages: [
{
role: "assistant",
content: {
type: "text",
text: JSON.stringify(formattedError, null, 2)
}
}
]
};
}
});
// List available resources (none for now)
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: []
};
});
// Handle resource requests (none for now)
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
throw new Error(`Unknown resource: ${uri}`);
});
}
async run() {
// Handle list-prompts option
if (argv['list-prompts']) {
try {
// Load prompts first
await this.loadPrompts();
console.log('\nAvailable Prompts:');
console.log('=================');
if (this.promptConfig && this.promptConfig.prompts.length > 0) {
console.log(`From: ${this.promptConfig.name}`);
console.log(`Description: ${this.promptConfig.description || 'No description'}`);
console.log();
for (const prompt of this.promptConfig.prompts) {
console.log(`• ${prompt.name}`);
console.log(` Description: ${prompt.description}`);
if (prompt.parameters && prompt.parameters.length > 0) {
console.log(` Parameters: ${prompt.parameters.map(p => p.name).join(', ')}`);
}
if (prompt.tags && prompt.tags.length > 0) {
console.log(` Tags: ${prompt.tags.join(', ')}`);
}
console.log();
}
} else {
console.log('No prompts loaded.');
}
// List available prompt files
const availableFiles = await this.promptLoader.getAvailablePromptFiles();
if (availableFiles.length > 0) {
console.log('Available Prompt Files:');
console.log('=====================');
for (const file of availableFiles) {
console.log(`• ${file.name} (${file.type})`);
console.log(` Path: ${file.path}`);
}
}
process.exit(0);
} catch (error) {
console.error('Error listing prompts:', error.message);
process.exit(1);
}
}
// Load prompts on startup
await this.loadPrompts();
const transport = new StdioServerTransport();
await this.server.connect(transport);
// Force an immediate test log entry
await logger.info('MCP-YNAB Server Starting', {
version: '1.2.0',
environment: {
NODE_ENV: process.env.NODE_ENV,
MCP_DEBUG: process.env.MCP_DEBUG,
loggerEnabled: logger.enabled
}
});
// Log server startup
logger.startup('MCP server started', {
toolCount: this.tools.size,
promptCount: this.prompts.size,
tools: Array.from(this.tools.keys()),
prompts: Array.from(this.prompts.keys())
});
}
}
// Start the server
const server = new YNABMCPServer();
server.run().catch((error) => {
// Server failed to start - exit silently to avoid MCP protocol interference
process.exit(1);
});