@trailfolio/mcp 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +78 -0
- package/dist/formatting.d.ts +14 -0
- package/dist/formatting.js +336 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +32 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +325 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Trailfolio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @trailfolio/mcp
|
|
2
|
+
|
|
3
|
+
Canadian financial calculator tools for AI assistants, via the [Model Context Protocol](https://modelcontextprotocol.io/).
|
|
4
|
+
|
|
5
|
+
Mortgage affordability, income tax, closing costs, Canada Child Benefit, Smith Manoeuvre, and more — powered by the [Trailfolio](https://trailfolio.com) API.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
### 1. Get an API key
|
|
10
|
+
|
|
11
|
+
Create one at [trailfolio.com/settings/api-keys](https://trailfolio.com/settings/api-keys).
|
|
12
|
+
|
|
13
|
+
### 2. Configure your MCP client
|
|
14
|
+
|
|
15
|
+
#### Claude Desktop
|
|
16
|
+
|
|
17
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"Trailfolio": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "@trailfolio/mcp"],
|
|
25
|
+
"env": {
|
|
26
|
+
"TRAILFOLIO_API_KEY": "tf_live_..."
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Windows:** Use `cmd` as the command:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"Trailfolio": {
|
|
39
|
+
"command": "cmd",
|
|
40
|
+
"args": ["/c", "npx", "-y", "@trailfolio/mcp"],
|
|
41
|
+
"env": {
|
|
42
|
+
"TRAILFOLIO_API_KEY": "tf_live_..."
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
#### Claude Code
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
claude mcp add trailfolio -- npx -y @trailfolio/mcp
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then set your API key as an environment variable or in your shell profile:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export TRAILFOLIO_API_KEY="tf_live_..."
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Environment Variables
|
|
62
|
+
|
|
63
|
+
| Variable | Required | Default | Description |
|
|
64
|
+
|----------|----------|---------|-------------|
|
|
65
|
+
| `TRAILFOLIO_API_KEY` | Yes | — | API key from [trailfolio.com/settings/api-keys](https://trailfolio.com/settings/api-keys) |
|
|
66
|
+
| `TRAILFOLIO_API_URL` | No | `https://api.trailfolio.com/v1` | API base URL override |
|
|
67
|
+
|
|
68
|
+
## Available Tools
|
|
69
|
+
|
|
70
|
+
| Tool | Description |
|
|
71
|
+
|------|-------------|
|
|
72
|
+
| `calculate_mortgage_affordability` | Max purchase price with stress test (CMHC/OSFI rules) |
|
|
73
|
+
| `calculate_buyer_closing_costs` | Land transfer tax, CMHC insurance, legal fees |
|
|
74
|
+
| `calculate_seller_closing_costs` | Realtor commission, legal fees, discharge fee |
|
|
75
|
+
| `calculate_canadian_income_tax` | Federal + provincial tax, CPP/EI, dividends, capital gains |
|
|
76
|
+
| `get_tax_constants` | CPP/EI rates, RRSP/FHSA limits, dividend gross-up rates |
|
|
77
|
+
| `calculate_mortgage_timeline` | Amortization schedule with renewal/refinance events |
|
|
78
|
+
| `calculate_canada_child_benefit` | CCB monthly payments with income phaseout |
|
|
79
|
+
| `calculate_heloc_interest` | Interest split: tax-deductible vs personal |
|
|
80
|
+
| `calculate_smith_manoeuvre` | Convert mortgage debt to deductible investment debt |
|
|
81
|
+
|
|
82
|
+
All tools use `snake_case` parameters (MCP convention). The server translates to `camelCase` for the API.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Trailfolio API.
|
|
3
|
+
*/
|
|
4
|
+
export type ApiResponse = Record<string, any>;
|
|
5
|
+
export declare class TrailfolioAPIError extends Error {
|
|
6
|
+
readonly statusCode: number;
|
|
7
|
+
readonly detail: string;
|
|
8
|
+
constructor(statusCode: number, detail: string);
|
|
9
|
+
}
|
|
10
|
+
export declare class TrailfolioClient {
|
|
11
|
+
private baseUrl;
|
|
12
|
+
private apiKey;
|
|
13
|
+
constructor(baseUrl: string, apiKey: string);
|
|
14
|
+
private get authHeaders();
|
|
15
|
+
post(path: string, body: Record<string, unknown>): Promise<ApiResponse>;
|
|
16
|
+
get(path: string): Promise<ApiResponse>;
|
|
17
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Trailfolio API.
|
|
3
|
+
*/
|
|
4
|
+
export class TrailfolioAPIError extends Error {
|
|
5
|
+
statusCode;
|
|
6
|
+
detail;
|
|
7
|
+
constructor(statusCode, detail) {
|
|
8
|
+
super(`API error ${statusCode}: ${detail}`);
|
|
9
|
+
this.statusCode = statusCode;
|
|
10
|
+
this.detail = detail;
|
|
11
|
+
this.name = "TrailfolioAPIError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class TrailfolioClient {
|
|
15
|
+
baseUrl;
|
|
16
|
+
apiKey;
|
|
17
|
+
constructor(baseUrl, apiKey) {
|
|
18
|
+
this.baseUrl = baseUrl;
|
|
19
|
+
this.apiKey = apiKey;
|
|
20
|
+
}
|
|
21
|
+
get authHeaders() {
|
|
22
|
+
return { "X-API-Key": this.apiKey };
|
|
23
|
+
}
|
|
24
|
+
async post(path, body) {
|
|
25
|
+
const payload = Object.fromEntries(Object.entries(body).filter(([, v]) => v !== undefined && v !== null));
|
|
26
|
+
const url = `${this.baseUrl}/calculators/${path}`;
|
|
27
|
+
let res;
|
|
28
|
+
try {
|
|
29
|
+
res = await fetch(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
...this.authHeaders,
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify(payload),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new TrailfolioAPIError(0, `Connection error: ${err instanceof Error ? err.message : String(err)}`);
|
|
40
|
+
}
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
let detail;
|
|
43
|
+
try {
|
|
44
|
+
const json = await res.json();
|
|
45
|
+
detail = json.detail ?? res.statusText;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
detail = res.statusText;
|
|
49
|
+
}
|
|
50
|
+
throw new TrailfolioAPIError(res.status, detail);
|
|
51
|
+
}
|
|
52
|
+
return res.json();
|
|
53
|
+
}
|
|
54
|
+
async get(path) {
|
|
55
|
+
const url = `${this.baseUrl}/calculators/${path}`;
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await fetch(url, {
|
|
59
|
+
headers: this.authHeaders,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new TrailfolioAPIError(0, `Connection error: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
let detail;
|
|
67
|
+
try {
|
|
68
|
+
const json = await res.json();
|
|
69
|
+
detail = json.detail ?? res.statusText;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
detail = res.statusText;
|
|
73
|
+
}
|
|
74
|
+
throw new TrailfolioAPIError(res.status, detail);
|
|
75
|
+
}
|
|
76
|
+
return res.json();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response formatters — convert API JSON responses to structured text for LLM consumption.
|
|
3
|
+
*/
|
|
4
|
+
import type { ApiResponse } from "./client.js";
|
|
5
|
+
export declare function formatAffordability(data: ApiResponse): string;
|
|
6
|
+
export declare function formatBuyerClosingCosts(data: ApiResponse): string;
|
|
7
|
+
export declare function formatSellerClosingCosts(data: ApiResponse): string;
|
|
8
|
+
export declare function formatMortgageTimeline(data: ApiResponse): string;
|
|
9
|
+
export declare function formatIncomeTax(data: ApiResponse): string;
|
|
10
|
+
export declare function formatTaxConstants(data: ApiResponse): string;
|
|
11
|
+
export declare function formatCCB(data: ApiResponse): string;
|
|
12
|
+
export declare function formatTFSARoom(data: ApiResponse): string;
|
|
13
|
+
export declare function formatHELOC(data: ApiResponse): string;
|
|
14
|
+
export declare function formatSmithManoeuvre(data: ApiResponse): string;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response formatters — convert API JSON responses to structured text for LLM consumption.
|
|
3
|
+
*/
|
|
4
|
+
function fmt(n) {
|
|
5
|
+
if (typeof n !== "number" || !Number.isFinite(n))
|
|
6
|
+
return "N/A";
|
|
7
|
+
return n.toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
8
|
+
}
|
|
9
|
+
function pct(n) {
|
|
10
|
+
if (typeof n !== "number" || !Number.isFinite(n))
|
|
11
|
+
return "N/A";
|
|
12
|
+
return `${(n * 100).toFixed(2)}%`;
|
|
13
|
+
}
|
|
14
|
+
function appendWarnings(lines, data) {
|
|
15
|
+
const warnings = data.warnings;
|
|
16
|
+
if (warnings && warnings.length > 0) {
|
|
17
|
+
lines.push("", "### Warnings");
|
|
18
|
+
for (const w of warnings) {
|
|
19
|
+
lines.push(`- ${w}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function formatAffordability(data) {
|
|
24
|
+
const lines = [
|
|
25
|
+
"## Mortgage Affordability Results",
|
|
26
|
+
"",
|
|
27
|
+
`Maximum Purchase Price: $${fmt(data.maximumPurchasePrice)}`,
|
|
28
|
+
`Maximum Mortgage: $${fmt(data.maximumMortgageAmount)}`,
|
|
29
|
+
`Monthly Mortgage Payment: $${fmt(data.monthlyMortgagePayment)}`,
|
|
30
|
+
`Total Monthly Housing Cost: $${fmt(data.totalMonthlyHousingCost)}`,
|
|
31
|
+
"",
|
|
32
|
+
"### Monthly Breakdown",
|
|
33
|
+
` Mortgage: $${fmt(data.monthlyMortgagePayment)}`,
|
|
34
|
+
` Property Tax: $${fmt(data.monthlyPropertyTax)}`,
|
|
35
|
+
` Heating: $${fmt(data.monthlyHeating)}`,
|
|
36
|
+
` Condo Fees: $${fmt(data.monthlyCondoFees)}`,
|
|
37
|
+
"",
|
|
38
|
+
"### Stress Test",
|
|
39
|
+
`Stress Test Rate: ${pct(data.stressTestRate)}`,
|
|
40
|
+
`Stress Test Payment: $${fmt(data.stressTestPayment)}`,
|
|
41
|
+
`Passes Stress Test: ${data.passesStressTest ? "Yes" : "No"}`,
|
|
42
|
+
"",
|
|
43
|
+
"### Debt Service Ratios",
|
|
44
|
+
`GDS Ratio: ${pct(data.gdsRatio)} (limit ${pct(data.gdsLimit)})`,
|
|
45
|
+
`TDS Ratio: ${pct(data.tdsRatio)} (limit ${pct(data.tdsLimit)})`,
|
|
46
|
+
];
|
|
47
|
+
if (data.requiresCmhcInsurance) {
|
|
48
|
+
lines.push("", "### CMHC Insurance (down payment < 20%)", `Premium Rate: ${pct(data.cmhcPremiumRate)}`, `Premium Amount: $${fmt(data.cmhcPremiumAmount)}`, `Total Mortgage with Insurance: $${fmt(data.totalMortgageWithInsurance)}`);
|
|
49
|
+
}
|
|
50
|
+
appendWarnings(lines, data);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
export function formatBuyerClosingCosts(data) {
|
|
54
|
+
const lines = [
|
|
55
|
+
"## Buyer Closing Costs",
|
|
56
|
+
"",
|
|
57
|
+
`Purchase Price: $${fmt(data.purchasePrice)}`,
|
|
58
|
+
`Down Payment: $${fmt(data.downPayment)} (${pct(data.downPaymentPercent)})`,
|
|
59
|
+
`Mortgage Amount: $${fmt(data.mortgageAmount)}`,
|
|
60
|
+
"",
|
|
61
|
+
"### Closing Cost Breakdown",
|
|
62
|
+
`Land Transfer Tax: $${fmt(data.landTransferTax)}`,
|
|
63
|
+
`Legal Fees: $${fmt(data.legalFees)}`,
|
|
64
|
+
`Title Insurance: $${fmt(data.titleInsurance)}`,
|
|
65
|
+
`Home Inspection: $${fmt(data.homeInspection)}`,
|
|
66
|
+
];
|
|
67
|
+
if (data.cmhcInsurance > 0) {
|
|
68
|
+
lines.push(`CMHC Insurance: $${fmt(data.cmhcInsurance)} (rate: ${pct(data.cmhcRate)})`);
|
|
69
|
+
}
|
|
70
|
+
lines.push("", "### Totals", `Total Closing Costs: $${fmt(data.totalClosingCosts)}`, `Total Cash Needed: $${fmt(data.totalCashNeeded)} (down payment + closing costs)`);
|
|
71
|
+
const ltt = data.landTransferTaxDetails;
|
|
72
|
+
if (ltt) {
|
|
73
|
+
lines.push("", `### Land Transfer Tax Details (${ltt.province})`);
|
|
74
|
+
if (ltt.name)
|
|
75
|
+
lines.push(`Tax Name: ${ltt.name}`);
|
|
76
|
+
if (ltt.gross != null)
|
|
77
|
+
lines.push(`Gross: $${fmt(ltt.gross)}`);
|
|
78
|
+
if (ltt.exemption != null && ltt.exemption > 0)
|
|
79
|
+
lines.push(`Exemption: -$${fmt(ltt.exemption)}`);
|
|
80
|
+
if (ltt.note)
|
|
81
|
+
lines.push(`Note: ${ltt.note}`);
|
|
82
|
+
}
|
|
83
|
+
appendWarnings(lines, data);
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
export function formatSellerClosingCosts(data) {
|
|
87
|
+
const lines = [
|
|
88
|
+
"## Seller Closing Costs",
|
|
89
|
+
"",
|
|
90
|
+
`Sale Price: $${fmt(data.salePrice)}`,
|
|
91
|
+
"",
|
|
92
|
+
"### Commission",
|
|
93
|
+
`Commission: $${fmt(data.commission)}`,
|
|
94
|
+
`Structure: ${data.commissionRateDescription}`,
|
|
95
|
+
`Tax on Commission: $${fmt(data.commissionTax)}`,
|
|
96
|
+
`Total Commission: $${fmt(data.commissionTotal)}`,
|
|
97
|
+
"",
|
|
98
|
+
"### Other Costs",
|
|
99
|
+
`Legal Fees: $${fmt(data.legalFees)}`,
|
|
100
|
+
`Mortgage Discharge: $${fmt(data.mortgageDischarge)}`,
|
|
101
|
+
"",
|
|
102
|
+
"### Totals",
|
|
103
|
+
`Total Closing Costs: $${fmt(data.totalClosingCosts)}`,
|
|
104
|
+
`Net Proceeds: $${fmt(data.netProceeds)}`,
|
|
105
|
+
];
|
|
106
|
+
appendWarnings(lines, data);
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
|
109
|
+
export function formatMortgageTimeline(data) {
|
|
110
|
+
const lines = [
|
|
111
|
+
"## Mortgage Timeline",
|
|
112
|
+
"",
|
|
113
|
+
`Principal: $${fmt(data.initialPrincipal)}`,
|
|
114
|
+
`Initial Rate: ${pct(data.initialRate)}`,
|
|
115
|
+
`Term: ${data.initialTermYears} years`,
|
|
116
|
+
`Amortization: ${data.initialAmortizationYears} years`,
|
|
117
|
+
`Payment: $${fmt(data.initialPeriodicPayment)} (${data.paymentFrequency})`,
|
|
118
|
+
];
|
|
119
|
+
const terms = data.terms;
|
|
120
|
+
if (terms && terms.length > 0) {
|
|
121
|
+
lines.push("", "### Terms");
|
|
122
|
+
for (const term of terms) {
|
|
123
|
+
const endLabel = term.endYear != null ? `Year ${term.endYear}` : "End";
|
|
124
|
+
lines.push("", `**Term ${term.termNumber}** (Year ${term.startYear}–${endLabel})` +
|
|
125
|
+
(term.eventType ? ` [${term.eventType}]` : ""), ` Rate: ${pct(term.annualRate)} | Payment: $${fmt(term.periodicPayment)}`, ` Balance: $${fmt(term.initialBalance)} → $${fmt(term.finalBalance)}`, ` Principal: $${fmt(term.totalPrincipal)} | Interest: $${fmt(term.totalInterest)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const totals = data.totals;
|
|
129
|
+
if (totals) {
|
|
130
|
+
lines.push("", "### Lifetime Totals", `Total Principal: $${fmt(totals.totalPrincipal)}`, `Total Interest: $${fmt(totals.totalInterest)}`, `Total Payments: $${fmt(totals.totalPayment)}`, `Actual Amortization: ${totals.actualAmortizationYears} years`);
|
|
131
|
+
if (totals.interestSavingsVsOriginal > 0) {
|
|
132
|
+
lines.push(`Interest Savings vs Original: $${fmt(totals.interestSavingsVsOriginal)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return lines.join("\n");
|
|
136
|
+
}
|
|
137
|
+
export function formatIncomeTax(data) {
|
|
138
|
+
const summary = data.summary;
|
|
139
|
+
const taxes = data.taxes;
|
|
140
|
+
const payroll = data.payroll;
|
|
141
|
+
const income = data.income;
|
|
142
|
+
const deductions = data.deductions;
|
|
143
|
+
const lines = [
|
|
144
|
+
`## Canadian Income Tax — ${data.province} ${data.year}`,
|
|
145
|
+
"",
|
|
146
|
+
"### Summary",
|
|
147
|
+
`Taxable Income: $${fmt(summary.taxableIncome)}`,
|
|
148
|
+
`Total Tax: $${fmt(taxes.totalTax)}`,
|
|
149
|
+
`Effective Rate: ${pct(summary.effectiveRate)}`,
|
|
150
|
+
`Marginal Rate: ${pct(summary.marginalRate)}`,
|
|
151
|
+
`After-Tax Income: $${fmt(summary.afterTaxIncome)}`,
|
|
152
|
+
`Take-Home Income: $${fmt(summary.takeHomeIncome)}`,
|
|
153
|
+
"",
|
|
154
|
+
"### Income Breakdown",
|
|
155
|
+
`Total Income: $${fmt(income.totalIncome)}`,
|
|
156
|
+
];
|
|
157
|
+
if (income.employmentIncome > 0)
|
|
158
|
+
lines.push(` Employment: $${fmt(income.employmentIncome)}`);
|
|
159
|
+
if (income.selfEmploymentIncome > 0)
|
|
160
|
+
lines.push(` Self-Employment: $${fmt(income.selfEmploymentIncome)}`);
|
|
161
|
+
if (income.interestIncome > 0)
|
|
162
|
+
lines.push(` Interest: $${fmt(income.interestIncome)}`);
|
|
163
|
+
if (income.eligibleDividendsActual > 0)
|
|
164
|
+
lines.push(` Eligible Dividends: $${fmt(income.eligibleDividendsActual)} (grossed-up: $${fmt(income.eligibleDividendsGrossed)})`);
|
|
165
|
+
if (income.ineligibleDividendsActual > 0)
|
|
166
|
+
lines.push(` Ineligible Dividends: $${fmt(income.ineligibleDividendsActual)} (grossed-up: $${fmt(income.ineligibleDividendsGrossed)})`);
|
|
167
|
+
if (income.capitalGainsTotal > 0)
|
|
168
|
+
lines.push(` Capital Gains: $${fmt(income.capitalGainsTotal)} (taxable: $${fmt(income.taxableCapitalGains)}, inclusion rate: ${pct(income.capitalGainsInclusionRate)})`);
|
|
169
|
+
if (income.rentalIncome > 0)
|
|
170
|
+
lines.push(` Rental: $${fmt(income.rentalIncome)}`);
|
|
171
|
+
if (income.otherIncome > 0)
|
|
172
|
+
lines.push(` Other: $${fmt(income.otherIncome)}`);
|
|
173
|
+
if (deductions.totalDeductions > 0) {
|
|
174
|
+
lines.push("", "### Deductions");
|
|
175
|
+
if (deductions.rrspDeduction > 0)
|
|
176
|
+
lines.push(` RRSP: $${fmt(deductions.rrspDeduction)}`);
|
|
177
|
+
if (deductions.fhsaDeduction > 0)
|
|
178
|
+
lines.push(` FHSA: $${fmt(deductions.fhsaDeduction)}`);
|
|
179
|
+
lines.push(` Total Deductions: $${fmt(deductions.totalDeductions)}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push("", "### Tax Breakdown", `Federal Tax: $${fmt(taxes.federalTax)}`, `Provincial Tax: $${fmt(taxes.provincialTax)}`, `Total Tax: $${fmt(taxes.totalTax)}`);
|
|
182
|
+
lines.push("", "### Payroll Deductions", `CPP: $${fmt(payroll.cppContribution)}`);
|
|
183
|
+
if (payroll.cpp2Contribution > 0)
|
|
184
|
+
lines.push(`CPP2: $${fmt(payroll.cpp2Contribution)}`);
|
|
185
|
+
lines.push(`EI: $${fmt(payroll.eiPremium)}`);
|
|
186
|
+
if (payroll.qpipPremium > 0)
|
|
187
|
+
lines.push(`QPIP: $${fmt(payroll.qpipPremium)}`);
|
|
188
|
+
lines.push(`Total Payroll: $${fmt(payroll.totalPayrollDeductions)}`);
|
|
189
|
+
appendWarnings(lines, data);
|
|
190
|
+
return lines.join("\n");
|
|
191
|
+
}
|
|
192
|
+
export function formatTaxConstants(data) {
|
|
193
|
+
const lines = [
|
|
194
|
+
`## Canadian Tax Constants — ${data.year}`,
|
|
195
|
+
"",
|
|
196
|
+
"### CPP (Canada Pension Plan)",
|
|
197
|
+
`Employee Rate: ${pct(data.cppRateEmployee)}`,
|
|
198
|
+
`Self-Employed Rate: ${pct(data.cppRateSelfEmployed)}`,
|
|
199
|
+
`YMPE: $${fmt(data.cppYmpe)}`,
|
|
200
|
+
`Basic Exemption: $${fmt(data.cppYbe)}`,
|
|
201
|
+
`Max Contribution: $${fmt(data.cppMaxContribution)}`,
|
|
202
|
+
"",
|
|
203
|
+
"### CPP2",
|
|
204
|
+
`Rate: ${pct(data.cpp2Rate)}`,
|
|
205
|
+
`YAMPE: $${fmt(data.cpp2Yampe)}`,
|
|
206
|
+
`Max Contribution: $${fmt(data.cpp2MaxContribution)}`,
|
|
207
|
+
"",
|
|
208
|
+
"### EI (Employment Insurance)",
|
|
209
|
+
`Rate: ${pct(data.eiRate)}`,
|
|
210
|
+
`Max Insurable Earnings: $${fmt(data.eiMaxInsurable)}`,
|
|
211
|
+
`Max Premium: $${fmt(data.eiMaxPremium)}`,
|
|
212
|
+
"",
|
|
213
|
+
"### Savings Limits",
|
|
214
|
+
`RRSP Limit: $${fmt(data.rrspLimit)}`,
|
|
215
|
+
`FHSA Annual Limit: $${fmt(data.fhsaAnnualLimit)}`,
|
|
216
|
+
`FHSA Lifetime Limit: $${fmt(data.fhsaLifetimeLimit)}`,
|
|
217
|
+
"",
|
|
218
|
+
"### Dividends",
|
|
219
|
+
`Eligible Gross-Up: ${pct(data.eligibleDividendGrossup)}`,
|
|
220
|
+
`Eligible DTC (Federal): ${pct(data.eligibleDividendDtcFederal)}`,
|
|
221
|
+
`Ineligible Gross-Up: ${pct(data.ineligibleDividendGrossup)}`,
|
|
222
|
+
`Ineligible DTC (Federal): ${pct(data.ineligibleDividendDtcFederal)}`,
|
|
223
|
+
"",
|
|
224
|
+
"### Capital Gains",
|
|
225
|
+
`Inclusion Rate: ${pct(data.capitalGainsInclusionRate)}`,
|
|
226
|
+
];
|
|
227
|
+
if (data.capitalGainsTieredThreshold) {
|
|
228
|
+
lines.push(`Tiered Threshold: $${fmt(data.capitalGainsTieredThreshold)}`);
|
|
229
|
+
}
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
export function formatCCB(data) {
|
|
233
|
+
const lines = [
|
|
234
|
+
`## Canada Child Benefit — ${data.benefitYear}`,
|
|
235
|
+
"",
|
|
236
|
+
"### Family",
|
|
237
|
+
`Adjusted Family Net Income: $${fmt(data.adjustedFamilyNetIncome)}`,
|
|
238
|
+
`Children Under 6: ${data.childrenUnder6}`,
|
|
239
|
+
`Children 6-17: ${data.children6To17}`,
|
|
240
|
+
`Total Children: ${data.totalChildren}`,
|
|
241
|
+
];
|
|
242
|
+
if (data.childrenWithDisability > 0) {
|
|
243
|
+
lines.push(`Children with Disability: ${data.childrenWithDisability}`);
|
|
244
|
+
}
|
|
245
|
+
lines.push("", "### Base Benefits (Annual)", `Under 6: $${fmt(data.baseBenefitUnder6)}`, `Ages 6-17: $${fmt(data.baseBenefit6To17)}`, `Total Base: $${fmt(data.totalBaseBenefit)}`);
|
|
246
|
+
if (data.childDisabilityBenefit > 0) {
|
|
247
|
+
lines.push(`Child Disability Benefit: $${fmt(data.childDisabilityBenefit)} (not income-tested)`);
|
|
248
|
+
}
|
|
249
|
+
if (data.totalReduction > 0) {
|
|
250
|
+
lines.push("", "### Income Phaseout", `Threshold 1: $${fmt(data.threshold1)} | Threshold 2: $${fmt(data.threshold2)}`, `Reduction Rate 1: ${pct(data.phaseoutRate1)} | Reduction: $${fmt(data.reductionFromRate1)}`, `Reduction Rate 2: ${pct(data.phaseoutRate2)} | Reduction: $${fmt(data.reductionFromRate2)}`, `Total Reduction: $${fmt(data.totalReduction)}`);
|
|
251
|
+
}
|
|
252
|
+
lines.push("", "### Final Benefit", `Annual CCB: $${fmt(data.annualCCB)}`, `Monthly CCB: $${fmt(data.monthlyCCB)}`);
|
|
253
|
+
appendWarnings(lines, data);
|
|
254
|
+
return lines.join("\n");
|
|
255
|
+
}
|
|
256
|
+
export function formatTFSARoom(data) {
|
|
257
|
+
const lines = [
|
|
258
|
+
"## TFSA Contribution Room",
|
|
259
|
+
"",
|
|
260
|
+
`**Available Room:** $${fmt(data.availableRoom)}`,
|
|
261
|
+
`**Total Room Accumulated:** $${fmt(data.totalContributionRoom)}`,
|
|
262
|
+
`**Total Contributed:** $${fmt(data.totalContributions)}`,
|
|
263
|
+
`**Total Withdrawn:** $${fmt(data.totalWithdrawals)}`,
|
|
264
|
+
`**Current Balance:** $${fmt(data.currentBalance)}`,
|
|
265
|
+
"",
|
|
266
|
+
`**First Eligible Year:** ${data.firstEligibleYear}`,
|
|
267
|
+
`**Eligible Years:** ${data.eligibleYears}`,
|
|
268
|
+
];
|
|
269
|
+
const overContribution = data.overContributionAmount;
|
|
270
|
+
if (overContribution > 0) {
|
|
271
|
+
lines.push("", "### ⚠️ Over-Contribution Warning", `**Excess Amount:** $${fmt(overContribution)}`, `**Estimated Annual Penalty:** $${fmt(data.estimatedAnnualPenalty)} (1% per month)`, "Withdraw the excess immediately to stop penalties.");
|
|
272
|
+
}
|
|
273
|
+
const breakdown = data.yearlyBreakdown;
|
|
274
|
+
if (breakdown && breakdown.length > 0) {
|
|
275
|
+
lines.push("", "### Year-by-Year Breakdown", "| Year | Limit | Room Added | Contributed | Withdrawn | Restored | Available |", "|------|-------|-----------|-------------|-----------|----------|-----------|");
|
|
276
|
+
for (const row of breakdown) {
|
|
277
|
+
if (!row.eligible)
|
|
278
|
+
continue;
|
|
279
|
+
lines.push(`| ${row.year} | $${fmt(row.annualLimit)} | $${fmt(row.roomAdded)} | $${fmt(row.contributions)} | $${fmt(row.withdrawals)} | $${fmt(row.roomFromWithdrawals)} | $${fmt(row.availableRoom)} |`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
appendWarnings(lines, data);
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
export function formatHELOC(data) {
|
|
286
|
+
const lines = [
|
|
287
|
+
"## HELOC Interest Calculation",
|
|
288
|
+
"",
|
|
289
|
+
`Effective Rate: ${pct(data.effectiveRate)}`,
|
|
290
|
+
"",
|
|
291
|
+
"### Balances",
|
|
292
|
+
`Total Balance: $${fmt(data.totalBalance)}`,
|
|
293
|
+
`Investment (Deductible): $${fmt(data.investmentBalance)}`,
|
|
294
|
+
`Personal (Non-Deductible): $${fmt(data.personalBalance)}`,
|
|
295
|
+
`Available Credit: $${fmt(data.availableCredit)}`,
|
|
296
|
+
"",
|
|
297
|
+
"### Interest",
|
|
298
|
+
`Monthly Interest: $${fmt(data.monthlyInterest)}`,
|
|
299
|
+
`Annual Interest: $${fmt(data.annualInterest)}`,
|
|
300
|
+
`Tax-Deductible Interest: $${fmt(data.deductibleInterest)}`,
|
|
301
|
+
`Non-Deductible Interest: $${fmt(data.nonDeductibleInterest)}`,
|
|
302
|
+
`Deductible Ratio: ${pct(data.deductibleRatio)}`,
|
|
303
|
+
];
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
export function formatSmithManoeuvre(data) {
|
|
307
|
+
const lines = [
|
|
308
|
+
"## Smith Manoeuvre Projection",
|
|
309
|
+
"",
|
|
310
|
+
"### Summary",
|
|
311
|
+
`Years to Mortgage Payoff: ${data.yearsToMortgagePayoff}`,
|
|
312
|
+
`Total Tax Savings: $${fmt(data.totalTaxSavings)}`,
|
|
313
|
+
`Final Portfolio Value: $${fmt(data.finalPortfolioValue)}`,
|
|
314
|
+
`Final HELOC Balance: $${fmt(data.finalHelocBalance)}`,
|
|
315
|
+
`Net Benefit: $${fmt(data.netBenefit)}`,
|
|
316
|
+
"",
|
|
317
|
+
"### Comparison",
|
|
318
|
+
`Traditional Net Worth: $${fmt(data.traditionalNetWorth)}`,
|
|
319
|
+
`Smith Manoeuvre Net Worth: $${fmt(data.smithManoeuvreNetWorth)}`,
|
|
320
|
+
`Advantage: $${fmt(data.advantage)}`,
|
|
321
|
+
`ROI on Borrowed Capital: ${pct(data.roiOnBorrowedCapital)}`,
|
|
322
|
+
];
|
|
323
|
+
const yearly = data.yearlyResults;
|
|
324
|
+
if (yearly && yearly.length > 0) {
|
|
325
|
+
lines.push("", "### Year-by-Year Highlights");
|
|
326
|
+
lines.push("| Year | Mortgage | HELOC | Portfolio | Tax Refund | Net Worth |", "|------|----------|-------|-----------|------------|-----------|");
|
|
327
|
+
const milestones = [1, 5, 10, 15, 20, 25, 30];
|
|
328
|
+
for (const yr of yearly) {
|
|
329
|
+
if (milestones.includes(yr.year) ||
|
|
330
|
+
yr.year === yearly[yearly.length - 1].year) {
|
|
331
|
+
lines.push(`| ${yr.year} | $${fmt(yr.mortgageBalance)} | $${fmt(yr.helocBalance)} | $${fmt(yr.portfolioValue)} | $${fmt(yr.taxRefund)} | $${fmt(yr.netWorth)} |`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return lines.join("\n");
|
|
336
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Trailfolio MCP Server — Canadian financial calculator tools for AI assistants.
|
|
4
|
+
*
|
|
5
|
+
* Environment variables:
|
|
6
|
+
* TRAILFOLIO_API_KEY - API key for authenticating with the Trailfolio API (required)
|
|
7
|
+
* TRAILFOLIO_API_URL - Base URL override (default: https://api.trailfolio.com/v1)
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Trailfolio MCP Server — Canadian financial calculator tools for AI assistants.
|
|
4
|
+
*
|
|
5
|
+
* Environment variables:
|
|
6
|
+
* TRAILFOLIO_API_KEY - API key for authenticating with the Trailfolio API (required)
|
|
7
|
+
* TRAILFOLIO_API_URL - Base URL override (default: https://api.trailfolio.com/v1)
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { TrailfolioClient } from "./client.js";
|
|
15
|
+
import { registerTools } from "./tools.js";
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
18
|
+
const DEFAULT_API_URL = "https://api.trailfolio.com/v1";
|
|
19
|
+
const apiKey = process.env.TRAILFOLIO_API_KEY;
|
|
20
|
+
const baseUrl = process.env.TRAILFOLIO_API_URL ?? DEFAULT_API_URL;
|
|
21
|
+
if (!apiKey) {
|
|
22
|
+
console.error("Error: TRAILFOLIO_API_KEY is required. Get one at https://trailfolio.com/settings/api-keys");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const server = new McpServer({
|
|
26
|
+
name: "Trailfolio",
|
|
27
|
+
version: pkg.version,
|
|
28
|
+
});
|
|
29
|
+
const client = new TrailfolioClient(baseUrl, apiKey);
|
|
30
|
+
registerTools(server, client);
|
|
31
|
+
const transport = new StdioServerTransport();
|
|
32
|
+
await server.connect(transport);
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool definitions — register all Trailfolio calculator tools.
|
|
3
|
+
*
|
|
4
|
+
* Each tool maps snake_case MCP params → camelCase API body, calls the API,
|
|
5
|
+
* and formats the response as structured text for LLM consumption.
|
|
6
|
+
*/
|
|
7
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import type { TrailfolioClient } from "./client.js";
|
|
9
|
+
export declare function registerTools(server: McpServer, client: TrailfolioClient): void;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool definitions — register all Trailfolio calculator tools.
|
|
3
|
+
*
|
|
4
|
+
* Each tool maps snake_case MCP params → camelCase API body, calls the API,
|
|
5
|
+
* and formats the response as structured text for LLM consumption.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { TrailfolioAPIError } from "./client.js";
|
|
9
|
+
import { formatAffordability, formatBuyerClosingCosts, formatCCB, formatHELOC, formatIncomeTax, formatMortgageTimeline, formatSellerClosingCosts, formatSmithManoeuvre, formatTaxConstants, formatTFSARoom, } from "./formatting.js";
|
|
10
|
+
// ── Result helpers ──────────────────────────────────────────────────
|
|
11
|
+
function textResult(text) {
|
|
12
|
+
return { content: [{ type: "text", text }] };
|
|
13
|
+
}
|
|
14
|
+
function errorResult(err) {
|
|
15
|
+
const message = err instanceof TrailfolioAPIError
|
|
16
|
+
? `API Error (${err.statusCode}): ${err.detail}`
|
|
17
|
+
: `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
18
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
19
|
+
}
|
|
20
|
+
function parseJsonParam(value, paramName) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(value);
|
|
23
|
+
if (!Array.isArray(parsed)) {
|
|
24
|
+
return errorResult(new Error(`${paramName} must be a valid JSON array.`));
|
|
25
|
+
}
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return errorResult(new Error(`${paramName} must be a valid JSON array.`));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ── Schemas ─────────────────────────────────────────────────────────
|
|
33
|
+
const provinceCode = z
|
|
34
|
+
.string()
|
|
35
|
+
.default("BC")
|
|
36
|
+
.describe("Two-letter province code: BC, AB, SK, MB, ON, QC, NB, NS, PE, NL");
|
|
37
|
+
const schemas = {
|
|
38
|
+
affordability: {
|
|
39
|
+
gross_annual_income: z.number().describe("Gross annual household income in dollars"),
|
|
40
|
+
down_payment: z.number().describe("Available down payment in dollars"),
|
|
41
|
+
interest_rate: z.number().describe("Mortgage interest rate as decimal (e.g., 0.055 for 5.5%)"),
|
|
42
|
+
monthly_debts: z.number().default(0).describe("Total monthly debt payments (car loans, credit cards, etc.)"),
|
|
43
|
+
property_tax_annual: z.number().default(3000).describe("Estimated annual property tax (default $3,000)"),
|
|
44
|
+
heating_cost_monthly: z.number().default(100).describe("Estimated monthly heating cost (default $100)"),
|
|
45
|
+
condo_fees_monthly: z.number().default(0).describe("Monthly condo/strata fees if applicable"),
|
|
46
|
+
amortization_years: z.number().int().default(25).describe("Amortization period in years (5-30, max 25 if down payment < 20%)"),
|
|
47
|
+
},
|
|
48
|
+
buyerClosingCosts: {
|
|
49
|
+
purchase_price: z.number().describe("Property purchase price in dollars"),
|
|
50
|
+
province: provinceCode,
|
|
51
|
+
down_payment_percent: z.number().default(0.2).describe("Down payment as decimal (e.g., 0.10 for 10%, default 0.20)"),
|
|
52
|
+
city: z.string().optional().describe("City for municipal taxes (e.g., 'Toronto' for Toronto MLTT)"),
|
|
53
|
+
is_first_time_buyer: z.boolean().default(false).describe("Whether buyer qualifies for first-time buyer exemptions"),
|
|
54
|
+
legal_fees: z.number().default(1500).describe("Legal/notary fees (default $1,500)"),
|
|
55
|
+
title_insurance: z.number().default(300).describe("Title insurance cost (default $300)"),
|
|
56
|
+
home_inspection: z.number().default(500).describe("Home inspection cost (default $500)"),
|
|
57
|
+
},
|
|
58
|
+
sellerClosingCosts: {
|
|
59
|
+
sale_price: z.number().describe("Property sale price in dollars"),
|
|
60
|
+
province: provinceCode,
|
|
61
|
+
region: z.string().optional().describe("Regional commission variation (SK only: 'standard' or 'prince_albert')"),
|
|
62
|
+
commission_rate: z.number().optional().describe("Custom commission rate as decimal (e.g., 0.04 for 4%), overrides province default"),
|
|
63
|
+
flat_fee: z.number().optional().describe("Flat commission fee in dollars (overrides percentage-based commission)"),
|
|
64
|
+
buyer_agent_split: z.number().default(0.5).describe("Buyer agent's share of commission (default 0.5 = 50%)"),
|
|
65
|
+
legal_fees: z.number().default(1500).describe("Legal fees (default $1,500)"),
|
|
66
|
+
mortgage_discharge: z.number().default(350).describe("Mortgage discharge fee (default $350)"),
|
|
67
|
+
},
|
|
68
|
+
incomeTax: {
|
|
69
|
+
province: z.string().describe("Two-letter province code (e.g., BC, ON, QC)"),
|
|
70
|
+
income_sources_json: z.string().describe('JSON array of income sources. Each source: {"incomeType": "employment"|"self_employment"|' +
|
|
71
|
+
'"capital_gains"|"eligible_dividends"|"ineligible_dividends"|"interest"|"rental"|"other", ' +
|
|
72
|
+
'"grossAmount": number}. For capital_gains use "proceeds" and "adjustedCostBase" instead. ' +
|
|
73
|
+
"For self_employment, optionally include \"businessExpenses\". " +
|
|
74
|
+
'Example: \'[{"incomeType": "employment", "grossAmount": 85000}]\''),
|
|
75
|
+
year: z.number().int().default(2025).describe("Tax year (default 2025)"),
|
|
76
|
+
rrsp_contribution: z.number().default(0).describe("RRSP contribution to deduct"),
|
|
77
|
+
fhsa_contribution: z.number().default(0).describe("FHSA contribution to deduct"),
|
|
78
|
+
},
|
|
79
|
+
taxConstants: {
|
|
80
|
+
year: z.number().int().default(2025).describe("Tax year (2000-2030, default 2025)"),
|
|
81
|
+
},
|
|
82
|
+
mortgage: {
|
|
83
|
+
principal: z.number().describe("Initial mortgage principal in dollars"),
|
|
84
|
+
annual_rate: z.number().describe("Annual interest rate as decimal (e.g., 0.05 for 5%)"),
|
|
85
|
+
amortization_years: z.number().int().describe("Amortization period in years (1-35)"),
|
|
86
|
+
term_years: z.number().int().default(5).describe("Fixed-rate term in years (1-10, default 5)"),
|
|
87
|
+
payment_frequency: z.string().default("monthly").describe('Payment frequency: "monthly", "biweekly", "weekly", "accelerated_biweekly", "accelerated_weekly", "semi_monthly"'),
|
|
88
|
+
events_json: z.string().optional().describe("Optional JSON array of renewal/refinance events. Each event: " +
|
|
89
|
+
'{"type": "renewal"|"refinance", "afterYears": number, "newRate": number}. ' +
|
|
90
|
+
'Refinance can include "newAmortizationYears" and "lumpSumPayment". ' +
|
|
91
|
+
'Example: \'[{"type": "renewal", "afterYears": 5, "newRate": 0.045}]\''),
|
|
92
|
+
},
|
|
93
|
+
ccb: {
|
|
94
|
+
adjusted_family_net_income: z.number().describe("Adjusted Family Net Income (AFNI) from previous year's tax return"),
|
|
95
|
+
children_under_6: z.number().int().default(0).describe("Number of children under age 6"),
|
|
96
|
+
children_6_to_17: z.number().int().default(0).describe("Number of children aged 6-17"),
|
|
97
|
+
children_with_disability: z.number().int().default(0).describe("Number of children eligible for the disability tax credit"),
|
|
98
|
+
benefit_year: z.string().default("2025-2026").describe('Benefit year (July-June): "2025-2026", "2024-2025", "2023-2024", etc.'),
|
|
99
|
+
},
|
|
100
|
+
tfsaRoom: {
|
|
101
|
+
birth_year: z.number().int().describe("Year of birth (used to determine eligibility at age 18)"),
|
|
102
|
+
current_year: z.number().int().default(2026).describe("Year to calculate room for (default 2026)"),
|
|
103
|
+
tax_resident_since: z.number().int().optional().describe("Year became Canadian tax resident (omit if always resident)"),
|
|
104
|
+
contributions_json: z.string().optional().describe('JSON object of contributions by year. Example: \'{"2020": 6000, "2021": 6000}\''),
|
|
105
|
+
withdrawals_json: z.string().optional().describe('JSON object of withdrawals by year. Example: \'{"2023": 5000}\''),
|
|
106
|
+
},
|
|
107
|
+
heloc: {
|
|
108
|
+
credit_limit: z.number().describe("Maximum HELOC credit limit in dollars"),
|
|
109
|
+
prime_rate: z.number().describe("Current Bank of Canada prime rate as decimal (e.g., 0.0545 for 5.45%)"),
|
|
110
|
+
investment_balance: z.number().default(0).describe("Balance used for investments (tax-deductible portion)"),
|
|
111
|
+
personal_balance: z.number().default(0).describe("Balance used for personal purposes (non-deductible)"),
|
|
112
|
+
prime_spread: z.number().default(0.005).describe("Rate spread over prime (default 0.005 = 0.5%)"),
|
|
113
|
+
},
|
|
114
|
+
smithManoeuvre: {
|
|
115
|
+
property_value: z.number().describe("Current property value in dollars"),
|
|
116
|
+
mortgage_balance: z.number().describe("Current mortgage balance (must be <= 80% of property value)"),
|
|
117
|
+
mortgage_rate: z.number().describe("Annual mortgage interest rate as decimal (e.g., 0.05 for 5%)"),
|
|
118
|
+
amortization_years: z.number().int().describe("Remaining amortization period in years"),
|
|
119
|
+
heloc_rate: z.number().describe("HELOC annual interest rate as decimal (Prime + spread)"),
|
|
120
|
+
marginal_tax_rate: z.number().describe("Marginal tax rate as decimal (e.g., 0.43 for 43%)"),
|
|
121
|
+
investment_return: z.number().default(0.07).describe("Expected annual investment return as decimal (default 0.07 = 7%)"),
|
|
122
|
+
dividend_yield: z.number().default(0.03).describe("Expected dividend yield as decimal (default 0.03 = 3%)"),
|
|
123
|
+
payment_frequency: z.string().default("monthly").describe("Mortgage payment frequency (default 'monthly')"),
|
|
124
|
+
projection_years: z.number().int().default(25).describe("Number of years to project (default 25, max 30)"),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
/** Register a simple POST tool: snake_case params → camelCase body → format. */
|
|
128
|
+
function registerPostTool(server, client, name, description, inputSchema, endpoint, mapParams, format) {
|
|
129
|
+
server.registerTool(name, { description, inputSchema }, async (params) => {
|
|
130
|
+
try {
|
|
131
|
+
const result = await client.post(endpoint, mapParams(params));
|
|
132
|
+
return textResult(format(result));
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return errorResult(err);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
export function registerTools(server, client) {
|
|
140
|
+
// ── Mortgage Affordability ──────────────────────────────────────────
|
|
141
|
+
registerPostTool(server, client, "calculate_mortgage_affordability", "Calculate maximum affordable home price with Canadian mortgage stress test (CMHC/OSFI). " +
|
|
142
|
+
"Applies stress test: qualify at higher of 5.25% or contract rate + 2%. " +
|
|
143
|
+
"GDS ratio must be <= 39%, TDS ratio <= 44%.", schemas.affordability, "affordability", (p) => ({
|
|
144
|
+
grossAnnualIncome: p.gross_annual_income,
|
|
145
|
+
downPayment: p.down_payment,
|
|
146
|
+
interestRate: p.interest_rate,
|
|
147
|
+
monthlyDebts: p.monthly_debts,
|
|
148
|
+
propertyTaxAnnual: p.property_tax_annual,
|
|
149
|
+
heatingCostMonthly: p.heating_cost_monthly,
|
|
150
|
+
condoFeesMonthly: p.condo_fees_monthly,
|
|
151
|
+
amortizationYears: p.amortization_years,
|
|
152
|
+
}), formatAffordability);
|
|
153
|
+
// ── Buyer Closing Costs ─────────────────────────────────────────────
|
|
154
|
+
registerPostTool(server, client, "calculate_buyer_closing_costs", "Calculate all closing costs for a property buyer in Canada. " +
|
|
155
|
+
"Includes land transfer tax (province-specific), CMHC insurance if down payment < 20%, " +
|
|
156
|
+
"legal fees, title insurance, and home inspection.", schemas.buyerClosingCosts, "closing-costs/buyer", (p) => ({
|
|
157
|
+
purchasePrice: p.purchase_price,
|
|
158
|
+
province: p.province,
|
|
159
|
+
downPaymentPercent: p.down_payment_percent,
|
|
160
|
+
city: p.city,
|
|
161
|
+
isFirstTimeBuyer: p.is_first_time_buyer,
|
|
162
|
+
legalFees: p.legal_fees,
|
|
163
|
+
titleInsurance: p.title_insurance,
|
|
164
|
+
homeInspection: p.home_inspection,
|
|
165
|
+
}), formatBuyerClosingCosts);
|
|
166
|
+
// ── Seller Closing Costs ────────────────────────────────────────────
|
|
167
|
+
registerPostTool(server, client, "calculate_seller_closing_costs", "Calculate all closing costs for a property seller in Canada. " +
|
|
168
|
+
"Includes realtor commission (province-specific tiered rates), legal fees, " +
|
|
169
|
+
"and mortgage discharge fee.", schemas.sellerClosingCosts, "closing-costs/seller", (p) => ({
|
|
170
|
+
salePrice: p.sale_price,
|
|
171
|
+
province: p.province,
|
|
172
|
+
region: p.region,
|
|
173
|
+
commissionRate: p.commission_rate,
|
|
174
|
+
flatFee: p.flat_fee,
|
|
175
|
+
buyerAgentSplit: p.buyer_agent_split,
|
|
176
|
+
legalFees: p.legal_fees,
|
|
177
|
+
mortgageDischarge: p.mortgage_discharge,
|
|
178
|
+
}), formatSellerClosingCosts);
|
|
179
|
+
// ── Canada Child Benefit ────────────────────────────────────────────
|
|
180
|
+
registerPostTool(server, client, "calculate_canada_child_benefit", "Calculate the Canada Child Benefit (CCB) — monthly tax-free payments to eligible families. " +
|
|
181
|
+
"Benefits are reduced based on family income using a two-tier phaseout. " +
|
|
182
|
+
"The Child Disability Benefit is NOT reduced by income.", schemas.ccb, "ccb", (p) => ({
|
|
183
|
+
adjustedFamilyNetIncome: p.adjusted_family_net_income,
|
|
184
|
+
childrenUnder6: p.children_under_6,
|
|
185
|
+
children6To17: p.children_6_to_17,
|
|
186
|
+
childrenWithDisability: p.children_with_disability,
|
|
187
|
+
benefitYear: p.benefit_year,
|
|
188
|
+
}), formatCCB);
|
|
189
|
+
// ── TFSA Contribution Room (custom: JSON parsing) ─────────────────
|
|
190
|
+
server.registerTool("calculate_tfsa_contribution_room", {
|
|
191
|
+
description: "Calculate TFSA contribution room based on birth year, residency, contributions, and withdrawals. " +
|
|
192
|
+
"Accounts for annual limits (2009-2026), age 18 eligibility, tax residency, " +
|
|
193
|
+
"withdrawal room restoration (Jan 1 of following year), and over-contribution penalties (1% per month).",
|
|
194
|
+
inputSchema: schemas.tfsaRoom,
|
|
195
|
+
}, async (params) => {
|
|
196
|
+
const body = {
|
|
197
|
+
birthYear: params.birth_year,
|
|
198
|
+
currentYear: params.current_year,
|
|
199
|
+
};
|
|
200
|
+
if (params.tax_resident_since != null) {
|
|
201
|
+
body.taxResidentSince = params.tax_resident_since;
|
|
202
|
+
}
|
|
203
|
+
if (params.contributions_json) {
|
|
204
|
+
try {
|
|
205
|
+
body.contributionsByYear = JSON.parse(params.contributions_json);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return errorResult(new Error("contributions_json must be a valid JSON object."));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (params.withdrawals_json) {
|
|
212
|
+
try {
|
|
213
|
+
body.withdrawalsByYear = JSON.parse(params.withdrawals_json);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return errorResult(new Error("withdrawals_json must be a valid JSON object."));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const result = await client.post("tfsa-room", body);
|
|
221
|
+
return textResult(formatTFSARoom(result));
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
return errorResult(err);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
// ── HELOC Interest ──────────────────────────────────────────────────
|
|
228
|
+
registerPostTool(server, client, "calculate_heloc_interest", "Calculate HELOC interest with tax-deductible/non-deductible split. " +
|
|
229
|
+
"Canadian HELOCs charge interest at Prime + spread. Interest on money " +
|
|
230
|
+
"borrowed for income-producing investments is tax-deductible (Smith Manoeuvre).", schemas.heloc, "heloc", (p) => ({
|
|
231
|
+
creditLimit: p.credit_limit,
|
|
232
|
+
primeRate: p.prime_rate,
|
|
233
|
+
primeSpread: p.prime_spread,
|
|
234
|
+
investmentBalance: p.investment_balance,
|
|
235
|
+
personalBalance: p.personal_balance,
|
|
236
|
+
}), formatHELOC);
|
|
237
|
+
// ── Smith Manoeuvre ─────────────────────────────────────────────────
|
|
238
|
+
registerPostTool(server, client, "calculate_smith_manoeuvre", "Project the Smith Manoeuvre strategy over time. " +
|
|
239
|
+
"Converts non-deductible mortgage debt into tax-deductible investment debt. " +
|
|
240
|
+
"Shows year-by-year projections of tax savings, portfolio growth, " +
|
|
241
|
+
"and comparison vs traditional mortgage paydown.", schemas.smithManoeuvre, "smith-manoeuvre", (p) => ({
|
|
242
|
+
propertyValue: p.property_value,
|
|
243
|
+
mortgageBalance: p.mortgage_balance,
|
|
244
|
+
mortgageRate: p.mortgage_rate,
|
|
245
|
+
amortizationYears: p.amortization_years,
|
|
246
|
+
helocRate: p.heloc_rate,
|
|
247
|
+
marginalTaxRate: p.marginal_tax_rate,
|
|
248
|
+
investmentReturn: p.investment_return,
|
|
249
|
+
dividendYield: p.dividend_yield,
|
|
250
|
+
paymentFrequency: p.payment_frequency,
|
|
251
|
+
projectionYears: p.projection_years,
|
|
252
|
+
}), formatSmithManoeuvre);
|
|
253
|
+
// ── Canadian Income Tax (custom: JSON parsing) ──────────────────────
|
|
254
|
+
server.registerTool("calculate_canadian_income_tax", {
|
|
255
|
+
description: "Calculate Canadian federal and provincial income tax. " +
|
|
256
|
+
"Supports employment, self-employment, capital gains, eligible/ineligible dividends, " +
|
|
257
|
+
"interest, rental, and other income. Calculates CPP/EI payroll deductions.",
|
|
258
|
+
inputSchema: schemas.incomeTax,
|
|
259
|
+
}, async (params) => {
|
|
260
|
+
const parsed = parseJsonParam(params.income_sources_json, "income_sources_json");
|
|
261
|
+
if (!Array.isArray(parsed))
|
|
262
|
+
return parsed;
|
|
263
|
+
const body = {
|
|
264
|
+
province: params.province,
|
|
265
|
+
year: params.year,
|
|
266
|
+
incomeSources: parsed,
|
|
267
|
+
};
|
|
268
|
+
if (params.rrsp_contribution > 0 || params.fhsa_contribution > 0) {
|
|
269
|
+
body.deductions = {
|
|
270
|
+
rrspContribution: params.rrsp_contribution,
|
|
271
|
+
fhsaContribution: params.fhsa_contribution,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const result = await client.post("income-tax", body);
|
|
276
|
+
return textResult(formatIncomeTax(result));
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
return errorResult(err);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
// ── Tax Constants (GET endpoint) ───────────────────────────────────
|
|
283
|
+
server.registerTool("get_tax_constants", {
|
|
284
|
+
description: "Get Canadian tax constants for a specific year. " +
|
|
285
|
+
"Returns CPP/EI rates and limits, RRSP/FHSA contribution limits, " +
|
|
286
|
+
"dividend gross-up rates, and capital gains inclusion rates.",
|
|
287
|
+
inputSchema: schemas.taxConstants,
|
|
288
|
+
}, async (params) => {
|
|
289
|
+
try {
|
|
290
|
+
const result = await client.get(`income-tax/constants/${params.year}`);
|
|
291
|
+
return textResult(formatTaxConstants(result));
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return errorResult(err);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
// ── Mortgage Timeline (custom: JSON parsing) ───────────────────────
|
|
298
|
+
server.registerTool("calculate_mortgage_timeline", {
|
|
299
|
+
description: "Calculate a mortgage amortization timeline with optional renewal/refinance events. " +
|
|
300
|
+
"Uses Canadian semi-annual compounding. Shows term-by-term breakdown and lifetime totals.",
|
|
301
|
+
inputSchema: schemas.mortgage,
|
|
302
|
+
}, async (params) => {
|
|
303
|
+
let events;
|
|
304
|
+
if (params.events_json) {
|
|
305
|
+
const parsed = parseJsonParam(params.events_json, "events_json");
|
|
306
|
+
if (!Array.isArray(parsed))
|
|
307
|
+
return parsed;
|
|
308
|
+
events = parsed;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const result = await client.post("mortgage", {
|
|
312
|
+
principal: params.principal,
|
|
313
|
+
annualRate: params.annual_rate,
|
|
314
|
+
amortizationYears: params.amortization_years,
|
|
315
|
+
termYears: params.term_years,
|
|
316
|
+
paymentFrequency: params.payment_frequency,
|
|
317
|
+
events,
|
|
318
|
+
});
|
|
319
|
+
return textResult(formatMortgageTimeline(result));
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
return errorResult(err);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@trailfolio/mcp",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Trailfolio MCP Server — Canadian financial calculator tools for AI assistants",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Trailfolio",
|
|
7
|
+
"homepage": "https://trailfolio.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/gxsui/Trailfolio.git",
|
|
11
|
+
"directory": "src/mcp-server"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/gxsui/Trailfolio/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mcp",
|
|
16
|
+
"model-context-protocol",
|
|
17
|
+
"canadian",
|
|
18
|
+
"finance",
|
|
19
|
+
"mortgage",
|
|
20
|
+
"tax",
|
|
21
|
+
"calculator"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"trailfolio-mcp": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc && shx chmod +x dist/*.js",
|
|
32
|
+
"prepare": "npm run build",
|
|
33
|
+
"dev": "tsc --watch",
|
|
34
|
+
"start": "node dist/index.js",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"lint": "eslint src/"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
40
|
+
"zod": "^3.25.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@eslint/js": "^9.39.2",
|
|
44
|
+
"@types/node": "^25.2.1",
|
|
45
|
+
"eslint": "^9.39.2",
|
|
46
|
+
"shx": "^0.3.4",
|
|
47
|
+
"typescript": "^5.6.0",
|
|
48
|
+
"typescript-eslint": "^8.54.0",
|
|
49
|
+
"vitest": "^3.0.0"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|