@nestpilot/mcp-app 1.0.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/README.md +350 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +214 -0
- package/dist/cli/export-import.d.ts +6 -0
- package/dist/cli/export-import.js +132 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +168 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +171 -0
- package/dist/host-configs/cowork.json +11 -0
- package/dist/host-configs/goose.yaml +22 -0
- package/dist/host-configs/openclaw-manifest.json +16 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +128 -0
- package/dist/mcp-app.html +155 -0
- package/dist/nestpilot-client.d.ts +44 -0
- package/dist/nestpilot-client.js +160 -0
- package/dist/planner.html +222 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.js +245 -0
- package/dist/skills/SKILL.md +162 -0
- package/dist/skills/manifest.json +51 -0
- package/dist/skills/tools/activate_plan.md +36 -0
- package/dist/skills/tools/coach.md +59 -0
- package/dist/skills/tools/comprehensive_plan.md +65 -0
- package/dist/skills/tools/create_plan.md +59 -0
- package/dist/skills/tools/create_saved_plan.md +49 -0
- package/dist/skills/tools/delete_plan.md +42 -0
- package/dist/skills/tools/delete_scenario.md +38 -0
- package/dist/skills/tools/generate_proposal.md +63 -0
- package/dist/skills/tools/generate_retirement_report.md +50 -0
- package/dist/skills/tools/get_active_plan.md +44 -0
- package/dist/skills/tools/get_baseline_forecast.md +47 -0
- package/dist/skills/tools/get_plan.md +44 -0
- package/dist/skills/tools/get_plan_components.md +50 -0
- package/dist/skills/tools/get_scenario.md +46 -0
- package/dist/skills/tools/list_plans.md +44 -0
- package/dist/skills/tools/list_scenarios.md +42 -0
- package/dist/skills/tools/medicare-guardian.md +59 -0
- package/dist/skills/tools/nestpilot_run_plan.md +61 -0
- package/dist/skills/tools/optimize_roth_conversion.md +107 -0
- package/dist/skills/tools/optimize_ss_claiming.md +30 -0
- package/dist/skills/tools/rename_plan.md +34 -0
- package/dist/skills/tools/retirement-planner.md +55 -0
- package/dist/skills/tools/run_forecast.md +65 -0
- package/dist/skills/tools/run_saved_forecast.md +52 -0
- package/dist/skills/tools/run_scenario.md +66 -0
- package/dist/skills/tools/save_plan.md +48 -0
- package/dist/skills/tools/save_scenario.md +50 -0
- package/dist/skills/tools/verify_forecast.md +43 -0
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +44 -0
- package/dist/src/contracts/provenance.d.ts +37 -0
- package/dist/src/contracts/provenance.js +71 -0
- package/dist/src/contracts/tool-contract-registry.d.ts +43 -0
- package/dist/src/contracts/tool-contract-registry.js +282 -0
- package/dist/src/local/cloud-compute-client.d.ts +55 -0
- package/dist/src/local/cloud-compute-client.js +135 -0
- package/dist/src/local/encryption.d.ts +24 -0
- package/dist/src/local/encryption.js +105 -0
- package/dist/src/local/keychain.d.ts +41 -0
- package/dist/src/local/keychain.js +236 -0
- package/dist/src/local/local-config.d.ts +34 -0
- package/dist/src/local/local-config.js +61 -0
- package/dist/src/local/local-data-layer.d.ts +20 -0
- package/dist/src/local/local-data-layer.js +15 -0
- package/dist/src/local/local-plan-store.d.ts +66 -0
- package/dist/src/local/local-plan-store.js +195 -0
- package/dist/src/local/pii-scrubber.d.ts +26 -0
- package/dist/src/local/pii-scrubber.js +219 -0
- package/dist/src/policy/policy-engine.d.ts +44 -0
- package/dist/src/policy/policy-engine.js +119 -0
- package/dist/src/rate-limit.d.ts +17 -0
- package/dist/src/rate-limit.js +41 -0
- package/dist/src/security.d.ts +19 -0
- package/dist/src/security.js +118 -0
- package/dist/src/skills/index.d.ts +12 -0
- package/dist/src/skills/index.js +16 -0
- package/dist/src/skills/retirement-pack-v1.d.ts +28 -0
- package/dist/src/skills/retirement-pack-v1.js +295 -0
- package/dist/src/skills/skill-executor.d.ts +65 -0
- package/dist/src/skills/skill-executor.js +174 -0
- package/dist/src/skills/skill-manifest-schema.d.ts +337 -0
- package/dist/src/skills/skill-manifest-schema.js +94 -0
- package/dist/src/skills/skill-registry.d.ts +71 -0
- package/dist/src/skills/skill-registry.js +116 -0
- package/dist/src/telemetry.d.ts +12 -0
- package/dist/src/telemetry.js +59 -0
- package/dist/src/types.d.ts +46 -0
- package/dist/src/types.js +4 -0
- package/dist/tools/agent-tools.d.ts +12 -0
- package/dist/tools/agent-tools.js +141 -0
- package/dist/tools/forecast-management-tools.d.ts +9 -0
- package/dist/tools/forecast-management-tools.js +133 -0
- package/dist/tools/local-plan-tools.d.ts +8 -0
- package/dist/tools/local-plan-tools.js +357 -0
- package/dist/tools/mcp-helpers.d.ts +52 -0
- package/dist/tools/mcp-helpers.js +177 -0
- package/dist/tools/medicare-tools.d.ts +3 -0
- package/dist/tools/medicare-tools.js +162 -0
- package/dist/tools/optimize-roth-tools-test.d.ts +2 -0
- package/dist/tools/optimize-roth-tools-test.js +36 -0
- package/dist/tools/optimize-roth-tools.d.ts +3 -0
- package/dist/tools/optimize-roth-tools.js +818 -0
- package/dist/tools/plan-management-tools.d.ts +3 -0
- package/dist/tools/plan-management-tools.js +196 -0
- package/dist/tools/planning-tools.d.ts +3 -0
- package/dist/tools/planning-tools.js +290 -0
- package/dist/tools/proposal-tools.d.ts +3 -0
- package/dist/tools/proposal-tools.js +428 -0
- package/dist/tools/report-tools.d.ts +3 -0
- package/dist/tools/report-tools.js +245 -0
- package/dist/tools/scenario-management-tools.d.ts +3 -0
- package/dist/tools/scenario-management-tools.js +136 -0
- package/dist/views/verification-packet.html +211 -0
- package/host-configs/cowork.json +11 -0
- package/host-configs/goose.yaml +22 -0
- package/host-configs/openclaw-manifest.json +16 -0
- package/package.json +66 -0
- package/skills/SKILL.md +162 -0
- package/skills/manifest.json +51 -0
- package/skills/tools/activate_plan.md +36 -0
- package/skills/tools/coach.md +59 -0
- package/skills/tools/comprehensive_plan.md +65 -0
- package/skills/tools/create_plan.md +59 -0
- package/skills/tools/create_saved_plan.md +49 -0
- package/skills/tools/delete_plan.md +42 -0
- package/skills/tools/delete_scenario.md +38 -0
- package/skills/tools/generate_proposal.md +63 -0
- package/skills/tools/generate_retirement_report.md +50 -0
- package/skills/tools/get_active_plan.md +44 -0
- package/skills/tools/get_baseline_forecast.md +47 -0
- package/skills/tools/get_plan.md +44 -0
- package/skills/tools/get_plan_components.md +50 -0
- package/skills/tools/get_scenario.md +46 -0
- package/skills/tools/list_plans.md +44 -0
- package/skills/tools/list_scenarios.md +42 -0
- package/skills/tools/medicare-guardian.md +59 -0
- package/skills/tools/nestpilot_run_plan.md +61 -0
- package/skills/tools/optimize_roth_conversion.md +107 -0
- package/skills/tools/optimize_ss_claiming.md +30 -0
- package/skills/tools/rename_plan.md +34 -0
- package/skills/tools/retirement-planner.md +55 -0
- package/skills/tools/run_forecast.md +65 -0
- package/skills/tools/run_saved_forecast.md +52 -0
- package/skills/tools/run_scenario.md +66 -0
- package/skills/tools/save_plan.md +48 -0
- package/skills/tools/save_scenario.md +50 -0
- package/skills/tools/verify_forecast.md +43 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proposal generation MCP tool — FEAT-0068
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates existing NestPilot APIs to assemble a client-ready advisor proposal:
|
|
5
|
+
* 1. POST /api/plans/{planId}/summary/generate — plan summary PDF
|
|
6
|
+
* 2. POST /api/advisor/clients/{clientUserId}/proposals — create proposal record
|
|
7
|
+
* 3. (optional) POST …/proposals/{id}/release — release to client
|
|
8
|
+
*
|
|
9
|
+
* Gated to Advisor tier via EntitlementService / policy engine (FEAT-0063).
|
|
10
|
+
*
|
|
11
|
+
* @feature FEAT-0068
|
|
12
|
+
*/
|
|
13
|
+
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { nestpilotClient } from "../nestpilot-client.js";
|
|
16
|
+
import { toCallToolResult } from "./mcp-helpers.js";
|
|
17
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
18
|
+
function buildAuthHeaders(authCtx) {
|
|
19
|
+
const headers = {};
|
|
20
|
+
if (authCtx?.bearerToken) {
|
|
21
|
+
headers.Authorization = `Bearer ${authCtx.bearerToken}`;
|
|
22
|
+
}
|
|
23
|
+
if (authCtx?.userId) {
|
|
24
|
+
headers["X-User-ID"] = authCtx.userId;
|
|
25
|
+
}
|
|
26
|
+
return headers;
|
|
27
|
+
}
|
|
28
|
+
function isError(result) {
|
|
29
|
+
return result.error === true;
|
|
30
|
+
}
|
|
31
|
+
// ── Tool registration ────────────────────────────────────────────────────
|
|
32
|
+
export function registerProposalTools(server, authCtx) {
|
|
33
|
+
registerAppTool(server, "generate_proposal", {
|
|
34
|
+
title: "Generate Advisor Proposal",
|
|
35
|
+
description: `Assembles a client-ready retirement planning proposal for an advisor.
|
|
36
|
+
|
|
37
|
+
Orchestrates three steps:
|
|
38
|
+
1. Generates a plan summary PDF via SummaryController.
|
|
39
|
+
2. Creates a structured proposal record linked to the plan and client.
|
|
40
|
+
3. Optionally releases the proposal to the client immediately.
|
|
41
|
+
|
|
42
|
+
USE THIS TOOL WHEN THE ADVISOR:
|
|
43
|
+
- Wants to package analysis into a formal deliverable for a client
|
|
44
|
+
- Has reviewed scenarios/optimizations and is ready to share findings
|
|
45
|
+
- Asks "create a proposal for my client" or "generate a proposal"
|
|
46
|
+
- Wants to bundle forecast + Roth/SS analysis into one document
|
|
47
|
+
|
|
48
|
+
DO NOT USE for internal analysis — use run_forecast or optimize_roth_conversion instead.
|
|
49
|
+
DO NOT USE if the advisor has not reviewed the plan first.
|
|
50
|
+
|
|
51
|
+
SAFETY GATES:
|
|
52
|
+
- Proposal is NOT released unless autoRelease is explicitly true.
|
|
53
|
+
- Advisor must confirm before release (prompt if autoRelease is true).
|
|
54
|
+
|
|
55
|
+
GATED: Advisor tier only.`,
|
|
56
|
+
inputSchema: {
|
|
57
|
+
planId: z
|
|
58
|
+
.string()
|
|
59
|
+
.uuid()
|
|
60
|
+
.describe("Plan ID to base the proposal on"),
|
|
61
|
+
clientUserId: z
|
|
62
|
+
.string()
|
|
63
|
+
.uuid()
|
|
64
|
+
.describe("Client user ID the proposal is for"),
|
|
65
|
+
title: z
|
|
66
|
+
.string()
|
|
67
|
+
.describe("Proposal title — shown to the client"),
|
|
68
|
+
subtitle: z
|
|
69
|
+
.string()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Optional subtitle or tagline"),
|
|
72
|
+
advisorNotes: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Custom advisor notes to include in the proposal"),
|
|
76
|
+
includeScenarios: z
|
|
77
|
+
.array(z.string().uuid())
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Scenario IDs to include in the proposal comparison"),
|
|
80
|
+
includeRothAnalysis: z
|
|
81
|
+
.boolean()
|
|
82
|
+
.optional()
|
|
83
|
+
.default(false)
|
|
84
|
+
.describe("Include Roth conversion analysis summary"),
|
|
85
|
+
includeSsAnalysis: z
|
|
86
|
+
.boolean()
|
|
87
|
+
.optional()
|
|
88
|
+
.default(false)
|
|
89
|
+
.describe("Include Social Security claiming analysis summary"),
|
|
90
|
+
unlockPriceCents: z
|
|
91
|
+
.number()
|
|
92
|
+
.int()
|
|
93
|
+
.min(0)
|
|
94
|
+
.optional()
|
|
95
|
+
.default(0)
|
|
96
|
+
.describe("Client unlock price in cents (0 = free)"),
|
|
97
|
+
autoRelease: z
|
|
98
|
+
.boolean()
|
|
99
|
+
.optional()
|
|
100
|
+
.default(false)
|
|
101
|
+
.describe("Release proposal to client immediately. Advisor must explicitly set true."),
|
|
102
|
+
},
|
|
103
|
+
_meta: {
|
|
104
|
+
ui: {
|
|
105
|
+
visibility: ["model", "app"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
}, async (args) => {
|
|
109
|
+
const { planId, clientUserId, title, subtitle, advisorNotes, includeScenarios, includeRothAnalysis = false, includeSsAnalysis = false, unlockPriceCents = 0, autoRelease = false, } = args;
|
|
110
|
+
const authHeaders = buildAuthHeaders(authCtx);
|
|
111
|
+
// ── Step 1: Generate plan summary PDF ───────────────────────────
|
|
112
|
+
const summaryResult = await nestpilotClient.post(`/api/plans/${planId}/summary/generate`, {}, { headers: authHeaders });
|
|
113
|
+
if (isError(summaryResult)) {
|
|
114
|
+
return toCallToolResult({
|
|
115
|
+
error: true,
|
|
116
|
+
step: "summary_generation",
|
|
117
|
+
message: summaryResult.message ?? "Failed to generate plan summary",
|
|
118
|
+
detail: summaryResult,
|
|
119
|
+
}, true);
|
|
120
|
+
}
|
|
121
|
+
const summaryUrl = summaryResult.pdfUrl ??
|
|
122
|
+
summaryResult.downloadUrl ??
|
|
123
|
+
null;
|
|
124
|
+
// ── Step 2: Build explanation markdown from advisor notes ────────
|
|
125
|
+
const explanationParts = [];
|
|
126
|
+
if (advisorNotes) {
|
|
127
|
+
explanationParts.push(`## Advisor Notes\n\n${advisorNotes}`);
|
|
128
|
+
}
|
|
129
|
+
if (includeRothAnalysis) {
|
|
130
|
+
explanationParts.push("## Roth Conversion Analysis\n\nRoth conversion optimization was performed. See attached analysis.");
|
|
131
|
+
}
|
|
132
|
+
if (includeSsAnalysis) {
|
|
133
|
+
explanationParts.push("## Social Security Claiming Analysis\n\nSS claiming optimization was performed. See attached analysis.");
|
|
134
|
+
}
|
|
135
|
+
const explanationMarkdown = explanationParts.length > 0 ? explanationParts.join("\n\n") : undefined;
|
|
136
|
+
// ── Step 3: Create proposal record ──────────────────────────────
|
|
137
|
+
const proposalPayload = {
|
|
138
|
+
planId,
|
|
139
|
+
title,
|
|
140
|
+
subtitle: subtitle ?? null,
|
|
141
|
+
unlockPriceCents,
|
|
142
|
+
impactSummary: {
|
|
143
|
+
summaryPdfUrl: summaryUrl,
|
|
144
|
+
includesRothAnalysis: includeRothAnalysis,
|
|
145
|
+
includesSsAnalysis: includeSsAnalysis,
|
|
146
|
+
},
|
|
147
|
+
explanationMarkdown: explanationMarkdown ?? null,
|
|
148
|
+
actionDetails: [],
|
|
149
|
+
scenarioIds: includeScenarios ?? [],
|
|
150
|
+
tags: [
|
|
151
|
+
"mcp-generated",
|
|
152
|
+
...(includeRothAnalysis ? ["roth-analysis"] : []),
|
|
153
|
+
...(includeSsAnalysis ? ["ss-analysis"] : []),
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
const createResult = await nestpilotClient.post(`/api/advisor/clients/${clientUserId}/proposals`, proposalPayload, { headers: authHeaders });
|
|
157
|
+
if (isError(createResult)) {
|
|
158
|
+
return toCallToolResult({
|
|
159
|
+
error: true,
|
|
160
|
+
step: "proposal_creation",
|
|
161
|
+
message: createResult.message ?? "Failed to create proposal record",
|
|
162
|
+
detail: createResult,
|
|
163
|
+
summaryGenerated: !!summaryUrl,
|
|
164
|
+
summaryUrl,
|
|
165
|
+
}, true);
|
|
166
|
+
}
|
|
167
|
+
const proposalId = createResult.id ??
|
|
168
|
+
createResult.proposalId;
|
|
169
|
+
// ── Step 4: Optional auto-release ───────────────────────────────
|
|
170
|
+
let releaseResult = null;
|
|
171
|
+
if (autoRelease && proposalId) {
|
|
172
|
+
releaseResult = await nestpilotClient.post(`/api/advisor/clients/${clientUserId}/proposals/${proposalId}/release`, {}, { headers: authHeaders });
|
|
173
|
+
if (isError(releaseResult)) {
|
|
174
|
+
// Proposal was created but release failed — return partial success
|
|
175
|
+
return toCallToolResult({
|
|
176
|
+
proposalId,
|
|
177
|
+
status: "draft",
|
|
178
|
+
warning: "Proposal was created but automatic release failed. Release it manually from the Proposal Manager.",
|
|
179
|
+
releaseError: releaseResult.message,
|
|
180
|
+
summaryUrl,
|
|
181
|
+
proposal: createResult,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ── Assemble response ────────────────────────────────────────────
|
|
186
|
+
const status = autoRelease && releaseResult ? "released" : "draft";
|
|
187
|
+
const response = {
|
|
188
|
+
proposalId,
|
|
189
|
+
status,
|
|
190
|
+
planId,
|
|
191
|
+
clientUserId,
|
|
192
|
+
title,
|
|
193
|
+
summaryUrl,
|
|
194
|
+
autoReleased: autoRelease && status === "released",
|
|
195
|
+
evidencePacket: {
|
|
196
|
+
planSummaryGenerated: true,
|
|
197
|
+
summaryUrl,
|
|
198
|
+
includesRothAnalysis: includeRothAnalysis,
|
|
199
|
+
includesSsAnalysis: includeSsAnalysis,
|
|
200
|
+
scenariosIncluded: includeScenarios?.length ?? 0,
|
|
201
|
+
generatedAt: new Date().toISOString(),
|
|
202
|
+
assumptions: [
|
|
203
|
+
"Plan summary generated from current saved plan state.",
|
|
204
|
+
"All projections are estimates and not guaranteed.",
|
|
205
|
+
"Tax calculations use current-year rates and may change.",
|
|
206
|
+
],
|
|
207
|
+
disclaimers: [
|
|
208
|
+
"This proposal is for educational purposes only.",
|
|
209
|
+
"Consult a licensed financial advisor before making investment decisions.",
|
|
210
|
+
"Past performance does not guarantee future results.",
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
proposal: createResult,
|
|
214
|
+
releaseResult: releaseResult ?? undefined,
|
|
215
|
+
};
|
|
216
|
+
return toCallToolResult(response);
|
|
217
|
+
});
|
|
218
|
+
/**
|
|
219
|
+
* FEAT-0079: Build an improvement proposal from a scenario with component additions.
|
|
220
|
+
*
|
|
221
|
+
* Orchestrates the full advisor improvement flow:
|
|
222
|
+
* 1. Save scenario with componentAdditions on client's plan
|
|
223
|
+
* 2. Run resilience on baseline (or reuse existing report)
|
|
224
|
+
* 3. Run resilience on scenario
|
|
225
|
+
* 4. Create proposal linking scenario + both resilience reports
|
|
226
|
+
* 5. Optionally auto-release
|
|
227
|
+
*
|
|
228
|
+
* @feature FEAT-0079
|
|
229
|
+
*/
|
|
230
|
+
registerAppTool(server, "build_improvement_proposal", {
|
|
231
|
+
title: "Build Improvement Proposal",
|
|
232
|
+
description: `Creates an advisor proposal that demonstrates plan improvement via scenario comparison.
|
|
233
|
+
|
|
234
|
+
Orchestrates five steps:
|
|
235
|
+
1. Saves a scenario with component additions (e.g. annuity, insurance) on the client's plan.
|
|
236
|
+
2. Runs resilience stress test on the baseline plan.
|
|
237
|
+
3. Runs resilience stress test on the scenario (with added components).
|
|
238
|
+
4. Creates a proposal linking the scenario and both resilience reports (before/after).
|
|
239
|
+
5. Optionally releases the proposal to the client.
|
|
240
|
+
|
|
241
|
+
The client sees a before/after resilience grade comparison when they unlock the proposal, and can "adopt" the scenario to make it their new baseline.
|
|
242
|
+
|
|
243
|
+
USE THIS TOOL WHEN THE ADVISOR:
|
|
244
|
+
- Has identified improvements for a client's plan (e.g. adding an annuity)
|
|
245
|
+
- Wants to show the client a before/after comparison
|
|
246
|
+
- Says "build an improvement proposal" or "show the client how this helps"
|
|
247
|
+
- Wants to bundle scenario + resilience comparison into a proposal
|
|
248
|
+
|
|
249
|
+
DO NOT USE for simple verbal proposals — use generate_proposal instead.
|
|
250
|
+
|
|
251
|
+
GATED: Advisor tier only.`,
|
|
252
|
+
inputSchema: {
|
|
253
|
+
planId: z.string().uuid().describe("UUID of the client's plan"),
|
|
254
|
+
clientUserId: z.string().uuid().describe("Client user UUID"),
|
|
255
|
+
title: z.string().describe("Proposal title shown to the client"),
|
|
256
|
+
subtitle: z.string().optional().describe("Optional subtitle"),
|
|
257
|
+
scenarioLabel: z
|
|
258
|
+
.string()
|
|
259
|
+
.describe("Name for the improvement scenario (e.g. 'Add Nationwide HighPoint 365')"),
|
|
260
|
+
componentAdditions: z
|
|
261
|
+
.array(z.object({
|
|
262
|
+
componentId: z.string().describe("Product component ID"),
|
|
263
|
+
version: z.string().optional(),
|
|
264
|
+
name: z.string().describe("Display name"),
|
|
265
|
+
params: z.record(z.any()).describe("Configuration parameters"),
|
|
266
|
+
enabled: z.boolean().optional().default(true),
|
|
267
|
+
}))
|
|
268
|
+
.describe("Financial components to add in this scenario"),
|
|
269
|
+
planDeltas: z
|
|
270
|
+
.array(z.record(z.any()))
|
|
271
|
+
.optional()
|
|
272
|
+
.describe("Optional plan input overrides (e.g. adjust retire age, spending)"),
|
|
273
|
+
advisorNotes: z.string().optional().describe("Advisor notes in markdown"),
|
|
274
|
+
unlockPriceCents: z
|
|
275
|
+
.number()
|
|
276
|
+
.int()
|
|
277
|
+
.min(0)
|
|
278
|
+
.optional()
|
|
279
|
+
.default(0)
|
|
280
|
+
.describe("Client unlock price in cents (0 = free)"),
|
|
281
|
+
autoRelease: z
|
|
282
|
+
.boolean()
|
|
283
|
+
.optional()
|
|
284
|
+
.default(false)
|
|
285
|
+
.describe("Release proposal to client immediately"),
|
|
286
|
+
existingBaselineReportId: z
|
|
287
|
+
.string()
|
|
288
|
+
.uuid()
|
|
289
|
+
.optional()
|
|
290
|
+
.describe("Reuse an existing baseline resilience report ID instead of running a new one"),
|
|
291
|
+
},
|
|
292
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
293
|
+
}, async (args) => {
|
|
294
|
+
const { planId, clientUserId, title, subtitle, scenarioLabel, componentAdditions, planDeltas, advisorNotes, unlockPriceCents = 0, autoRelease = false, existingBaselineReportId, } = args;
|
|
295
|
+
const authHeaders = buildAuthHeaders(authCtx);
|
|
296
|
+
// ── Step 1: Save scenario with componentAdditions ────────────────
|
|
297
|
+
const scenarioPayload = {
|
|
298
|
+
label: scenarioLabel,
|
|
299
|
+
planId,
|
|
300
|
+
overrides: {
|
|
301
|
+
planDeltas: planDeltas ?? [],
|
|
302
|
+
componentOverrides: [],
|
|
303
|
+
componentAdditions,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
const scenarioResult = await nestpilotClient.post("/api/scenarios/save", scenarioPayload, { headers: authHeaders });
|
|
307
|
+
if (isError(scenarioResult)) {
|
|
308
|
+
return toCallToolResult({
|
|
309
|
+
error: true,
|
|
310
|
+
step: "save_scenario",
|
|
311
|
+
message: scenarioResult.message ?? "Failed to save scenario",
|
|
312
|
+
detail: scenarioResult,
|
|
313
|
+
}, true);
|
|
314
|
+
}
|
|
315
|
+
const scenarioId = scenarioResult.id ??
|
|
316
|
+
scenarioResult.scenarioId;
|
|
317
|
+
if (!scenarioId) {
|
|
318
|
+
return toCallToolResult({ error: true, step: "save_scenario", message: "Scenario saved but no ID returned" }, true);
|
|
319
|
+
}
|
|
320
|
+
// ── Step 2: Get plan data for resilience runs ───────────────────
|
|
321
|
+
const plan = await nestpilotClient.get(`/api/plans/${planId}`, undefined, {
|
|
322
|
+
headers: authHeaders,
|
|
323
|
+
});
|
|
324
|
+
if (isError(plan)) {
|
|
325
|
+
return toCallToolResult({
|
|
326
|
+
error: true,
|
|
327
|
+
step: "load_plan",
|
|
328
|
+
message: plan.message ?? "Failed to load plan",
|
|
329
|
+
scenarioId,
|
|
330
|
+
}, true);
|
|
331
|
+
}
|
|
332
|
+
// ── Step 3: Run baseline resilience (or reuse existing) ────────
|
|
333
|
+
let baselineReportId = existingBaselineReportId ?? null;
|
|
334
|
+
if (!baselineReportId) {
|
|
335
|
+
const baselineRes = await nestpilotClient.post("/api/resilience/run", { plan, planId, saveReport: true }, { headers: authHeaders, timeout: 120_000 });
|
|
336
|
+
if (isError(baselineRes)) {
|
|
337
|
+
return toCallToolResult({
|
|
338
|
+
error: true,
|
|
339
|
+
step: "baseline_resilience",
|
|
340
|
+
message: baselineRes.message ?? "Failed to run baseline resilience",
|
|
341
|
+
scenarioId,
|
|
342
|
+
}, true);
|
|
343
|
+
}
|
|
344
|
+
baselineReportId = baselineRes.reportId ?? null;
|
|
345
|
+
}
|
|
346
|
+
// ── Step 4: Run scenario resilience ─────────────────────────────
|
|
347
|
+
const scenarioRes = await nestpilotClient.post("/api/resilience/run", { plan, planId, scenarioId, saveReport: true }, { headers: authHeaders, timeout: 120_000 });
|
|
348
|
+
if (isError(scenarioRes)) {
|
|
349
|
+
return toCallToolResult({
|
|
350
|
+
error: true,
|
|
351
|
+
step: "scenario_resilience",
|
|
352
|
+
message: scenarioRes.message ?? "Failed to run scenario resilience",
|
|
353
|
+
scenarioId,
|
|
354
|
+
baselineReportId,
|
|
355
|
+
}, true);
|
|
356
|
+
}
|
|
357
|
+
const scenarioReportId = scenarioRes.reportId ?? null;
|
|
358
|
+
// ── Step 5: Create proposal with resilience links ───────────────
|
|
359
|
+
const explanationParts = [];
|
|
360
|
+
if (advisorNotes) {
|
|
361
|
+
explanationParts.push(`## Advisor Notes\n\n${advisorNotes}`);
|
|
362
|
+
}
|
|
363
|
+
explanationParts.push(`## Plan Improvement\n\nThis proposal includes a scenario ("${scenarioLabel}") ` +
|
|
364
|
+
`that demonstrates how adding ${componentAdditions.length} component(s) improves your plan's resilience.`);
|
|
365
|
+
const proposalPayload = {
|
|
366
|
+
planId,
|
|
367
|
+
title,
|
|
368
|
+
subtitle: subtitle ?? null,
|
|
369
|
+
unlockPriceCents,
|
|
370
|
+
impactSummary: {
|
|
371
|
+
baselineGrade: plan.overallGrade ?? null,
|
|
372
|
+
scenarioGrade: scenarioRes.overallGrade ?? null,
|
|
373
|
+
componentsAdded: componentAdditions.map((c) => c.name ?? c.componentId),
|
|
374
|
+
},
|
|
375
|
+
explanationMarkdown: explanationParts.join("\n\n"),
|
|
376
|
+
actionDetails: componentAdditions.map((c) => `Add ${c.name ?? c.componentId} to your plan`),
|
|
377
|
+
scenarioIds: [scenarioId],
|
|
378
|
+
tags: ["mcp-generated", "improvement-proposal"],
|
|
379
|
+
baselineResilienceReportId: baselineReportId,
|
|
380
|
+
scenarioResilienceReportId: scenarioReportId,
|
|
381
|
+
};
|
|
382
|
+
const createResult = await nestpilotClient.post(`/api/advisor/clients/${clientUserId}/proposals`, proposalPayload, { headers: authHeaders });
|
|
383
|
+
if (isError(createResult)) {
|
|
384
|
+
return toCallToolResult({
|
|
385
|
+
error: true,
|
|
386
|
+
step: "proposal_creation",
|
|
387
|
+
message: createResult.message ?? "Failed to create proposal",
|
|
388
|
+
scenarioId,
|
|
389
|
+
baselineReportId,
|
|
390
|
+
scenarioReportId,
|
|
391
|
+
}, true);
|
|
392
|
+
}
|
|
393
|
+
const proposalId = createResult.id ??
|
|
394
|
+
createResult.proposalId;
|
|
395
|
+
// ── Step 6: Optional auto-release ───────────────────────────────
|
|
396
|
+
let releaseResult = null;
|
|
397
|
+
if (autoRelease && proposalId) {
|
|
398
|
+
releaseResult = await nestpilotClient.post(`/api/advisor/clients/${clientUserId}/proposals/${proposalId}/release`, {}, { headers: authHeaders });
|
|
399
|
+
if (isError(releaseResult)) {
|
|
400
|
+
return toCallToolResult({
|
|
401
|
+
proposalId,
|
|
402
|
+
status: "draft",
|
|
403
|
+
warning: "Proposal created but auto-release failed. Release manually.",
|
|
404
|
+
releaseError: releaseResult.message,
|
|
405
|
+
scenarioId,
|
|
406
|
+
baselineReportId,
|
|
407
|
+
scenarioReportId,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const status = autoRelease && releaseResult ? "released" : "draft";
|
|
412
|
+
return toCallToolResult({
|
|
413
|
+
proposalId,
|
|
414
|
+
status,
|
|
415
|
+
planId,
|
|
416
|
+
clientUserId,
|
|
417
|
+
title,
|
|
418
|
+
scenarioId,
|
|
419
|
+
baselineReportId,
|
|
420
|
+
scenarioReportId,
|
|
421
|
+
autoReleased: autoRelease && status === "released",
|
|
422
|
+
componentsAdded: componentAdditions.map((c) => c.name ?? c.componentId),
|
|
423
|
+
message: `Improvement proposal ${status === "released" ? "released" : "created as draft"} successfully. ` +
|
|
424
|
+
`Scenario "${scenarioLabel}" saved with ${componentAdditions.length} component(s). ` +
|
|
425
|
+
`Resilience comparison: baseline report ${baselineReportId}, scenario report ${scenarioReportId}.`,
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report generation MCP tools — generate_retirement_report.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates plan retrieval, forecast execution, scenario loading,
|
|
5
|
+
* and PDF generation via the generate_report.py script.
|
|
6
|
+
*/
|
|
7
|
+
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { nestpilotClient } from "../nestpilot-client.js";
|
|
15
|
+
import { resolveSavedPlanId } from "./forecast-management-tools.js";
|
|
16
|
+
import { toCallToolResult } from "./mcp-helpers.js";
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
// Canonical script location: skills/retirement-report/scripts/ (single-source convention).
|
|
19
|
+
// Falls back to local mcp-app/scripts/ for backward compatibility.
|
|
20
|
+
const SKILL_SCRIPTS_DIR = path.resolve(import.meta.dirname, "..", "..", "..", "skills", "retirement-report", "scripts");
|
|
21
|
+
const LOCAL_SCRIPTS_DIR = path.join(import.meta.dirname, "..", "scripts");
|
|
22
|
+
async function resolveScriptsDir() {
|
|
23
|
+
try {
|
|
24
|
+
await fs.access(path.join(SKILL_SCRIPTS_DIR, "generate_report.py"));
|
|
25
|
+
return SKILL_SCRIPTS_DIR;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return LOCAL_SCRIPTS_DIR;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function buildAuthHeaders(authCtx) {
|
|
32
|
+
const headers = {};
|
|
33
|
+
if (authCtx?.bearerToken) {
|
|
34
|
+
headers.Authorization = `Bearer ${authCtx.bearerToken}`;
|
|
35
|
+
}
|
|
36
|
+
if (authCtx?.userId) {
|
|
37
|
+
headers["X-User-ID"] = authCtx.userId;
|
|
38
|
+
}
|
|
39
|
+
return headers;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Builds a forecast payload from a saved plan object.
|
|
43
|
+
*/
|
|
44
|
+
function buildForecastPayload(plan, planId) {
|
|
45
|
+
const household = plan.household;
|
|
46
|
+
const primary = household?.primary;
|
|
47
|
+
const goals = plan.goals;
|
|
48
|
+
const retirement = goals?.retirement;
|
|
49
|
+
const targetSpending = retirement?.targetSpending;
|
|
50
|
+
return {
|
|
51
|
+
currentAge: primary?.age,
|
|
52
|
+
retirementAge: primary?.retireAge,
|
|
53
|
+
horizonAge: primary?.horizonAge ?? 95,
|
|
54
|
+
targetMonthlySpending: targetSpending?.amountMonthly,
|
|
55
|
+
accounts: plan.accounts,
|
|
56
|
+
incomeStreams: plan.incomeStreams,
|
|
57
|
+
spendStreams: plan.spendStreams,
|
|
58
|
+
filingStatus: household?.filingStatus,
|
|
59
|
+
spouse: household?.spouse,
|
|
60
|
+
socialSecurityFRA: primary?.socialSecurity,
|
|
61
|
+
socialSecurityClaimAge: primary?.socialSecurityClaimAge,
|
|
62
|
+
strategy: plan.strategy,
|
|
63
|
+
taxProfile: plan.taxProfile,
|
|
64
|
+
assumptions: plan.assumptions,
|
|
65
|
+
planId,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function registerReportTools(server, authCtx) {
|
|
69
|
+
registerAppTool(server, "generate_retirement_report", {
|
|
70
|
+
title: "Generate Retirement Report",
|
|
71
|
+
description: `Generates a comprehensive retirement planning PDF report for a saved plan. Loads the plan, runs a forecast, optionally includes saved scenarios, and produces a professional PDF with plan details, charts, projections, and recommendations.
|
|
72
|
+
|
|
73
|
+
USE THIS TOOL WHEN THE USER:
|
|
74
|
+
- Wants a PDF document summarizing their retirement plan
|
|
75
|
+
- Asks to "create a retirement report" or "generate a retirement report"
|
|
76
|
+
- Needs a shareable/printable retirement forecast
|
|
77
|
+
- Wants to export their plan and forecast to PDF
|
|
78
|
+
- Requests a retirement readiness report in document format
|
|
79
|
+
|
|
80
|
+
REQUIRES a saved plan (planId or active plan). Returns the PDF file path.`,
|
|
81
|
+
inputSchema: {
|
|
82
|
+
planId: z
|
|
83
|
+
.string()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("UUID of the saved plan. Alias values 'default', 'active', or 'current' use the active plan. Omit to use the active plan."),
|
|
86
|
+
clientName: z
|
|
87
|
+
.string()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("Client name for personalizing the report header (e.g., 'John & Jane Doe')"),
|
|
90
|
+
includeScenarios: z
|
|
91
|
+
.boolean()
|
|
92
|
+
.optional()
|
|
93
|
+
.default(true)
|
|
94
|
+
.describe("Whether to include saved scenario comparisons in the report (default: true)"),
|
|
95
|
+
includeResilience: z
|
|
96
|
+
.boolean()
|
|
97
|
+
.optional()
|
|
98
|
+
.default(true)
|
|
99
|
+
.describe("Whether to include the latest resilience stress-test assessment in the report (default: true). Uses saved report if available; skips silently if none exists."),
|
|
100
|
+
outputPath: z
|
|
101
|
+
.string()
|
|
102
|
+
.optional()
|
|
103
|
+
.describe("Custom output path for the PDF. If omitted, generates in a temp directory."),
|
|
104
|
+
},
|
|
105
|
+
_meta: {
|
|
106
|
+
ui: {
|
|
107
|
+
visibility: ["model", "app"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}, async (args) => {
|
|
111
|
+
const { planId: rawPlanId, clientName, includeScenarios = true, includeResilience = true, outputPath, } = args;
|
|
112
|
+
// 1. Resolve plan ID
|
|
113
|
+
const resolved = await resolveSavedPlanId(rawPlanId, authCtx);
|
|
114
|
+
if (resolved.errorResult)
|
|
115
|
+
return resolved.errorResult;
|
|
116
|
+
if (!resolved.planId) {
|
|
117
|
+
return toCallToolResult({
|
|
118
|
+
error: true,
|
|
119
|
+
code: 400,
|
|
120
|
+
message: "No plan ID provided and no active plan found. Create or activate a saved plan first.",
|
|
121
|
+
}, true);
|
|
122
|
+
}
|
|
123
|
+
const planId = resolved.planId;
|
|
124
|
+
// 2. Get plan details
|
|
125
|
+
const plan = await nestpilotClient.get(`/api/plans/${planId}`, undefined, {
|
|
126
|
+
headers: buildAuthHeaders(authCtx),
|
|
127
|
+
});
|
|
128
|
+
if (plan.error) {
|
|
129
|
+
return toCallToolResult({
|
|
130
|
+
error: true,
|
|
131
|
+
code: plan.code ?? 500,
|
|
132
|
+
message: plan.message ?? `Failed to load plan ${planId}`,
|
|
133
|
+
}, true);
|
|
134
|
+
}
|
|
135
|
+
// 3. Run forecast
|
|
136
|
+
const forecastPayload = buildForecastPayload(plan, planId);
|
|
137
|
+
const forecast = await nestpilotClient.post("/api/plan/forecast", forecastPayload, {
|
|
138
|
+
headers: buildAuthHeaders(authCtx),
|
|
139
|
+
timeout: 60_000,
|
|
140
|
+
});
|
|
141
|
+
if (forecast.error) {
|
|
142
|
+
return toCallToolResult({
|
|
143
|
+
error: true,
|
|
144
|
+
code: forecast.code ?? 500,
|
|
145
|
+
message: forecast.message ?? "Failed to run forecast",
|
|
146
|
+
}, true);
|
|
147
|
+
}
|
|
148
|
+
// 4. Optionally load scenarios
|
|
149
|
+
let scenarios = null;
|
|
150
|
+
if (includeScenarios) {
|
|
151
|
+
const scenarioList = await nestpilotClient.get("/api/scenarios", {
|
|
152
|
+
planId,
|
|
153
|
+
}, {
|
|
154
|
+
headers: buildAuthHeaders(authCtx),
|
|
155
|
+
});
|
|
156
|
+
if (!scenarioList.error && Array.isArray(scenarioList)) {
|
|
157
|
+
scenarios = scenarioList;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 4b. Optionally load resilience report (saved, not live)
|
|
161
|
+
let resilienceReport = null;
|
|
162
|
+
if (includeResilience) {
|
|
163
|
+
try {
|
|
164
|
+
const resList = await nestpilotClient.get("/api/resilience/reports", {
|
|
165
|
+
planId,
|
|
166
|
+
}, {
|
|
167
|
+
headers: buildAuthHeaders(authCtx),
|
|
168
|
+
});
|
|
169
|
+
if (!resList.error && Array.isArray(resList) && resList.length > 0) {
|
|
170
|
+
resilienceReport = resList[0];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
// Non-fatal — skip resilience section silently
|
|
175
|
+
console.warn("[report-tools] Failed to load resilience report:", e);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// 5. Write temp files and run Python script
|
|
179
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "nestpilot-report-"));
|
|
180
|
+
const planFile = path.join(tmpDir, "plan.json");
|
|
181
|
+
const forecastFile = path.join(tmpDir, "forecast.json");
|
|
182
|
+
const pdfPath = outputPath ?? path.join(tmpDir, `retirement_report_${planId.slice(0, 8)}.pdf`);
|
|
183
|
+
await fs.writeFile(planFile, JSON.stringify(plan, null, 2));
|
|
184
|
+
await fs.writeFile(forecastFile, JSON.stringify(forecast, null, 2));
|
|
185
|
+
const scriptsDir = await resolveScriptsDir();
|
|
186
|
+
const pythonArgs = [
|
|
187
|
+
path.join(scriptsDir, "generate_report.py"),
|
|
188
|
+
"--output",
|
|
189
|
+
pdfPath,
|
|
190
|
+
"--plan-data",
|
|
191
|
+
planFile,
|
|
192
|
+
"--forecast-data",
|
|
193
|
+
forecastFile,
|
|
194
|
+
];
|
|
195
|
+
if (clientName) {
|
|
196
|
+
pythonArgs.push("--client-name", clientName);
|
|
197
|
+
}
|
|
198
|
+
if (scenarios && Array.isArray(scenarios)) {
|
|
199
|
+
const scenariosFile = path.join(tmpDir, "scenarios.json");
|
|
200
|
+
await fs.writeFile(scenariosFile, JSON.stringify(scenarios, null, 2));
|
|
201
|
+
pythonArgs.push("--scenarios-data", scenariosFile);
|
|
202
|
+
}
|
|
203
|
+
if (resilienceReport) {
|
|
204
|
+
const resilienceFile = path.join(tmpDir, "resilience.json");
|
|
205
|
+
await fs.writeFile(resilienceFile, JSON.stringify(resilienceReport, null, 2));
|
|
206
|
+
pythonArgs.push("--resilience-data", resilienceFile);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const { stdout, stderr } = await execFileAsync("python", pythonArgs, {
|
|
210
|
+
timeout: 120_000,
|
|
211
|
+
});
|
|
212
|
+
if (stderr && stderr.trim()) {
|
|
213
|
+
console.warn("[report-tools] Python stderr:", stderr);
|
|
214
|
+
}
|
|
215
|
+
// Verify PDF was created
|
|
216
|
+
try {
|
|
217
|
+
await fs.access(pdfPath);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return toCallToolResult({
|
|
221
|
+
error: true,
|
|
222
|
+
message: `PDF generation completed but file not found at ${pdfPath}. Python output: ${stdout}`,
|
|
223
|
+
}, true);
|
|
224
|
+
}
|
|
225
|
+
const stats = await fs.stat(pdfPath);
|
|
226
|
+
return toCallToolResult({
|
|
227
|
+
success: true,
|
|
228
|
+
pdfPath,
|
|
229
|
+
fileSizeBytes: stats.size,
|
|
230
|
+
planId,
|
|
231
|
+
includesScenarios: scenarios !== null,
|
|
232
|
+
includesResilience: resilienceReport !== null,
|
|
233
|
+
message: `Retirement report generated successfully: ${pdfPath}`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
const error = e;
|
|
238
|
+
return toCallToolResult({
|
|
239
|
+
error: true,
|
|
240
|
+
message: `PDF generation failed: ${error.message}`,
|
|
241
|
+
stderr: error.stderr,
|
|
242
|
+
}, true);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|