@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.
Files changed (149) hide show
  1. package/README.md +350 -0
  2. package/dist/cli/doctor.d.ts +1 -0
  3. package/dist/cli/doctor.js +214 -0
  4. package/dist/cli/export-import.d.ts +6 -0
  5. package/dist/cli/export-import.js +132 -0
  6. package/dist/cli/index.d.ts +2 -0
  7. package/dist/cli/index.js +168 -0
  8. package/dist/cli/init.d.ts +1 -0
  9. package/dist/cli/init.js +171 -0
  10. package/dist/host-configs/cowork.json +11 -0
  11. package/dist/host-configs/goose.yaml +22 -0
  12. package/dist/host-configs/openclaw-manifest.json +16 -0
  13. package/dist/main.d.ts +2 -0
  14. package/dist/main.js +128 -0
  15. package/dist/mcp-app.html +155 -0
  16. package/dist/nestpilot-client.d.ts +44 -0
  17. package/dist/nestpilot-client.js +160 -0
  18. package/dist/planner.html +222 -0
  19. package/dist/server.d.ts +19 -0
  20. package/dist/server.js +245 -0
  21. package/dist/skills/SKILL.md +162 -0
  22. package/dist/skills/manifest.json +51 -0
  23. package/dist/skills/tools/activate_plan.md +36 -0
  24. package/dist/skills/tools/coach.md +59 -0
  25. package/dist/skills/tools/comprehensive_plan.md +65 -0
  26. package/dist/skills/tools/create_plan.md +59 -0
  27. package/dist/skills/tools/create_saved_plan.md +49 -0
  28. package/dist/skills/tools/delete_plan.md +42 -0
  29. package/dist/skills/tools/delete_scenario.md +38 -0
  30. package/dist/skills/tools/generate_proposal.md +63 -0
  31. package/dist/skills/tools/generate_retirement_report.md +50 -0
  32. package/dist/skills/tools/get_active_plan.md +44 -0
  33. package/dist/skills/tools/get_baseline_forecast.md +47 -0
  34. package/dist/skills/tools/get_plan.md +44 -0
  35. package/dist/skills/tools/get_plan_components.md +50 -0
  36. package/dist/skills/tools/get_scenario.md +46 -0
  37. package/dist/skills/tools/list_plans.md +44 -0
  38. package/dist/skills/tools/list_scenarios.md +42 -0
  39. package/dist/skills/tools/medicare-guardian.md +59 -0
  40. package/dist/skills/tools/nestpilot_run_plan.md +61 -0
  41. package/dist/skills/tools/optimize_roth_conversion.md +107 -0
  42. package/dist/skills/tools/optimize_ss_claiming.md +30 -0
  43. package/dist/skills/tools/rename_plan.md +34 -0
  44. package/dist/skills/tools/retirement-planner.md +55 -0
  45. package/dist/skills/tools/run_forecast.md +65 -0
  46. package/dist/skills/tools/run_saved_forecast.md +52 -0
  47. package/dist/skills/tools/run_scenario.md +66 -0
  48. package/dist/skills/tools/save_plan.md +48 -0
  49. package/dist/skills/tools/save_scenario.md +50 -0
  50. package/dist/skills/tools/verify_forecast.md +43 -0
  51. package/dist/src/config.d.ts +20 -0
  52. package/dist/src/config.js +44 -0
  53. package/dist/src/contracts/provenance.d.ts +37 -0
  54. package/dist/src/contracts/provenance.js +71 -0
  55. package/dist/src/contracts/tool-contract-registry.d.ts +43 -0
  56. package/dist/src/contracts/tool-contract-registry.js +282 -0
  57. package/dist/src/local/cloud-compute-client.d.ts +55 -0
  58. package/dist/src/local/cloud-compute-client.js +135 -0
  59. package/dist/src/local/encryption.d.ts +24 -0
  60. package/dist/src/local/encryption.js +105 -0
  61. package/dist/src/local/keychain.d.ts +41 -0
  62. package/dist/src/local/keychain.js +236 -0
  63. package/dist/src/local/local-config.d.ts +34 -0
  64. package/dist/src/local/local-config.js +61 -0
  65. package/dist/src/local/local-data-layer.d.ts +20 -0
  66. package/dist/src/local/local-data-layer.js +15 -0
  67. package/dist/src/local/local-plan-store.d.ts +66 -0
  68. package/dist/src/local/local-plan-store.js +195 -0
  69. package/dist/src/local/pii-scrubber.d.ts +26 -0
  70. package/dist/src/local/pii-scrubber.js +219 -0
  71. package/dist/src/policy/policy-engine.d.ts +44 -0
  72. package/dist/src/policy/policy-engine.js +119 -0
  73. package/dist/src/rate-limit.d.ts +17 -0
  74. package/dist/src/rate-limit.js +41 -0
  75. package/dist/src/security.d.ts +19 -0
  76. package/dist/src/security.js +118 -0
  77. package/dist/src/skills/index.d.ts +12 -0
  78. package/dist/src/skills/index.js +16 -0
  79. package/dist/src/skills/retirement-pack-v1.d.ts +28 -0
  80. package/dist/src/skills/retirement-pack-v1.js +295 -0
  81. package/dist/src/skills/skill-executor.d.ts +65 -0
  82. package/dist/src/skills/skill-executor.js +174 -0
  83. package/dist/src/skills/skill-manifest-schema.d.ts +337 -0
  84. package/dist/src/skills/skill-manifest-schema.js +94 -0
  85. package/dist/src/skills/skill-registry.d.ts +71 -0
  86. package/dist/src/skills/skill-registry.js +116 -0
  87. package/dist/src/telemetry.d.ts +12 -0
  88. package/dist/src/telemetry.js +59 -0
  89. package/dist/src/types.d.ts +46 -0
  90. package/dist/src/types.js +4 -0
  91. package/dist/tools/agent-tools.d.ts +12 -0
  92. package/dist/tools/agent-tools.js +141 -0
  93. package/dist/tools/forecast-management-tools.d.ts +9 -0
  94. package/dist/tools/forecast-management-tools.js +133 -0
  95. package/dist/tools/local-plan-tools.d.ts +8 -0
  96. package/dist/tools/local-plan-tools.js +357 -0
  97. package/dist/tools/mcp-helpers.d.ts +52 -0
  98. package/dist/tools/mcp-helpers.js +177 -0
  99. package/dist/tools/medicare-tools.d.ts +3 -0
  100. package/dist/tools/medicare-tools.js +162 -0
  101. package/dist/tools/optimize-roth-tools-test.d.ts +2 -0
  102. package/dist/tools/optimize-roth-tools-test.js +36 -0
  103. package/dist/tools/optimize-roth-tools.d.ts +3 -0
  104. package/dist/tools/optimize-roth-tools.js +818 -0
  105. package/dist/tools/plan-management-tools.d.ts +3 -0
  106. package/dist/tools/plan-management-tools.js +196 -0
  107. package/dist/tools/planning-tools.d.ts +3 -0
  108. package/dist/tools/planning-tools.js +290 -0
  109. package/dist/tools/proposal-tools.d.ts +3 -0
  110. package/dist/tools/proposal-tools.js +428 -0
  111. package/dist/tools/report-tools.d.ts +3 -0
  112. package/dist/tools/report-tools.js +245 -0
  113. package/dist/tools/scenario-management-tools.d.ts +3 -0
  114. package/dist/tools/scenario-management-tools.js +136 -0
  115. package/dist/views/verification-packet.html +211 -0
  116. package/host-configs/cowork.json +11 -0
  117. package/host-configs/goose.yaml +22 -0
  118. package/host-configs/openclaw-manifest.json +16 -0
  119. package/package.json +66 -0
  120. package/skills/SKILL.md +162 -0
  121. package/skills/manifest.json +51 -0
  122. package/skills/tools/activate_plan.md +36 -0
  123. package/skills/tools/coach.md +59 -0
  124. package/skills/tools/comprehensive_plan.md +65 -0
  125. package/skills/tools/create_plan.md +59 -0
  126. package/skills/tools/create_saved_plan.md +49 -0
  127. package/skills/tools/delete_plan.md +42 -0
  128. package/skills/tools/delete_scenario.md +38 -0
  129. package/skills/tools/generate_proposal.md +63 -0
  130. package/skills/tools/generate_retirement_report.md +50 -0
  131. package/skills/tools/get_active_plan.md +44 -0
  132. package/skills/tools/get_baseline_forecast.md +47 -0
  133. package/skills/tools/get_plan.md +44 -0
  134. package/skills/tools/get_plan_components.md +50 -0
  135. package/skills/tools/get_scenario.md +46 -0
  136. package/skills/tools/list_plans.md +44 -0
  137. package/skills/tools/list_scenarios.md +42 -0
  138. package/skills/tools/medicare-guardian.md +59 -0
  139. package/skills/tools/nestpilot_run_plan.md +61 -0
  140. package/skills/tools/optimize_roth_conversion.md +107 -0
  141. package/skills/tools/optimize_ss_claiming.md +30 -0
  142. package/skills/tools/rename_plan.md +34 -0
  143. package/skills/tools/retirement-planner.md +55 -0
  144. package/skills/tools/run_forecast.md +65 -0
  145. package/skills/tools/run_saved_forecast.md +52 -0
  146. package/skills/tools/run_scenario.md +66 -0
  147. package/skills/tools/save_plan.md +48 -0
  148. package/skills/tools/save_scenario.md +50 -0
  149. package/skills/tools/verify_forecast.md +43 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Local Plan Tools — MCP tool registrations for NESTPILOT_MODE=local.
3
+ *
4
+ * Replaces the cloud-proxy planning tools with local-first alternatives:
5
+ * - CRUD operations use LocalPlanStore (encrypted ~/.nestpilot/plans/)
6
+ * - Compute operations (forecast, scenario, coach) scrub PII and call cloud
7
+ * - Cached results enable offline access for previously computed forecasts
8
+ *
9
+ * @feature FEAT-0087
10
+ */
11
+ import { registerAppTool, } from "@modelcontextprotocol/ext-apps/server";
12
+ import { z } from "zod";
13
+ import { toCallToolResult } from "./mcp-helpers.js";
14
+ import { scrubPII, validateNoPII } from "../src/local/pii-scrubber.js";
15
+ // ── Tool registration ────────────────────────────────────────────────────
16
+ /**
17
+ * Registers local-mode MCP tools on the server.
18
+ * These tools handle data locally and delegate compute to the cloud.
19
+ */
20
+ export function registerLocalPlanTools(server, store, computeClient) {
21
+ // ── 1. create_plan (local) ──────────────────────────────────────────
22
+ registerAppTool(server, "create_plan", {
23
+ title: "Create Retirement Plan",
24
+ description: `Creates a new retirement plan stored locally on your machine (~/.nestpilot/plans/).
25
+ Your financial data never leaves your computer. Only PII-free mathematical inputs are sent to the cloud for computation.
26
+
27
+ USE THIS TOOL WHEN THE USER:
28
+ - Wants to create a new retirement plan
29
+ - Provides financial details (age, accounts, income, spending)
30
+ - Says "help me plan for retirement" or "create a plan"`,
31
+ inputSchema: {
32
+ currentAge: z.number().int().min(18).describe("Current age"),
33
+ retirementAge: z.number().int().min(50).describe("Target retirement age"),
34
+ horizonAge: z.number().int().optional().describe("Planning horizon age (default: 95)"),
35
+ targetMonthlySpending: z.number().describe("Monthly spending target in retirement"),
36
+ accounts: z
37
+ .array(z.record(z.any()))
38
+ .optional()
39
+ .describe("Array of retirement accounts: {type, balance, annualContribution?, realReturn?}"),
40
+ incomeStreams: z
41
+ .array(z.record(z.any()))
42
+ .optional()
43
+ .describe("Array of income streams: {type, amountMonthly, startAge?, endAge?}"),
44
+ payload: z.record(z.any()).optional().describe("Full plan payload (alternative to individual fields)"),
45
+ },
46
+ _meta: {
47
+ ui: { visibility: ["model", "app"] },
48
+ },
49
+ }, async (args) => {
50
+ try {
51
+ const planData = args.payload
52
+ ?? args;
53
+ const localPlan = await store.create(planData);
54
+ return toCallToolResult({
55
+ planId: localPlan.id,
56
+ message: `Plan created and stored locally (${localPlan.id}). Your data is encrypted on your machine.`,
57
+ plan: localPlan.plan,
58
+ version: localPlan.version,
59
+ created: localPlan.created,
60
+ });
61
+ }
62
+ catch (e) {
63
+ return toCallToolResult({ error: true, message: `Failed to create plan: ${e instanceof Error ? e.message : String(e)}` }, true);
64
+ }
65
+ });
66
+ // ── 2. list_plans (local) ───────────────────────────────────────────
67
+ registerAppTool(server, "list_plans", {
68
+ title: "List Local Plans",
69
+ description: `Lists all retirement plans stored locally on your machine.
70
+ Works fully offline — no internet required.`,
71
+ inputSchema: {},
72
+ _meta: {
73
+ ui: { visibility: ["model", "app"] },
74
+ },
75
+ }, async () => {
76
+ try {
77
+ const plans = await store.list();
78
+ return toCallToolResult({
79
+ plans,
80
+ count: plans.length,
81
+ message: plans.length > 0
82
+ ? `Found ${plans.length} local plan(s).`
83
+ : "No plans found. Use create_plan to get started.",
84
+ });
85
+ }
86
+ catch (e) {
87
+ return toCallToolResult({ error: true, message: `Failed to list plans: ${e instanceof Error ? e.message : String(e)}` }, true);
88
+ }
89
+ });
90
+ // ── 3. get_plan (local) ─────────────────────────────────────────────
91
+ registerAppTool(server, "get_plan", {
92
+ title: "Get Plan Details",
93
+ description: `Retrieves full details of a locally stored retirement plan.
94
+ Works fully offline — no internet required.`,
95
+ inputSchema: {
96
+ planId: z.string().describe("UUID of the plan to retrieve"),
97
+ },
98
+ _meta: {
99
+ ui: { visibility: ["model", "app"] },
100
+ },
101
+ }, async (args) => {
102
+ try {
103
+ const { planId } = args;
104
+ const localPlan = await store.get(planId);
105
+ return toCallToolResult({
106
+ planId: localPlan.id,
107
+ plan: localPlan.plan,
108
+ version: localPlan.version,
109
+ created: localPlan.created,
110
+ updated: localPlan.updated,
111
+ metadata: localPlan.metadata,
112
+ });
113
+ }
114
+ catch (e) {
115
+ return toCallToolResult({ error: true, message: `Failed to get plan: ${e instanceof Error ? e.message : String(e)}` }, true);
116
+ }
117
+ });
118
+ // ── 4. save_plan (local) ────────────────────────────────────────────
119
+ registerAppTool(server, "save_plan", {
120
+ title: "Save Plan",
121
+ description: `Updates and saves an existing retirement plan locally.
122
+ Works fully offline — no internet required.`,
123
+ inputSchema: {
124
+ planId: z.string().describe("UUID of the plan to update"),
125
+ plan: z.record(z.any()).describe("Updated plan data"),
126
+ },
127
+ _meta: {
128
+ ui: { visibility: ["model", "app"] },
129
+ },
130
+ }, async (args) => {
131
+ try {
132
+ const { planId, plan } = args;
133
+ const updated = await store.save(planId, plan);
134
+ return toCallToolResult({
135
+ planId: updated.id,
136
+ version: updated.version,
137
+ updated: updated.updated,
138
+ message: "Plan saved successfully.",
139
+ });
140
+ }
141
+ catch (e) {
142
+ return toCallToolResult({ error: true, message: `Failed to save plan: ${e instanceof Error ? e.message : String(e)}` }, true);
143
+ }
144
+ });
145
+ // ── 5. delete_plan (local) ──────────────────────────────────────────
146
+ registerAppTool(server, "delete_plan", {
147
+ title: "Delete Plan",
148
+ description: `Permanently deletes a locally stored retirement plan and its cached data.
149
+ Works fully offline — no internet required.`,
150
+ inputSchema: {
151
+ planId: z.string().describe("UUID of the plan to delete"),
152
+ },
153
+ _meta: {
154
+ ui: { visibility: ["model", "app"] },
155
+ },
156
+ }, async (args) => {
157
+ try {
158
+ const { planId } = args;
159
+ await store.delete(planId);
160
+ return toCallToolResult({
161
+ message: `Plan ${planId} deleted.`,
162
+ });
163
+ }
164
+ catch (e) {
165
+ return toCallToolResult({ error: true, message: `Failed to delete plan: ${e instanceof Error ? e.message : String(e)}` }, true);
166
+ }
167
+ });
168
+ // ── 6. run_forecast (local → cloud compute) ────────────────────────
169
+ registerAppTool(server, "run_forecast", {
170
+ title: "Run Retirement Forecast",
171
+ description: `Runs a comprehensive retirement forecast. Your plan data stays local — only PII-free mathematical inputs (ages, dollar amounts, rates) are sent to the NestPilot compute cloud.
172
+
173
+ USE THIS TOOL WHEN THE USER:
174
+ - Wants year-by-year retirement projections
175
+ - Asks about portfolio runway or "When will I run out of money?"
176
+ - Has a saved plan and wants to run numbers
177
+
178
+ Requires internet connectivity for cloud compute. Cached results are returned if offline.`,
179
+ inputSchema: {
180
+ planId: z.string().optional().describe("UUID of a saved plan to forecast"),
181
+ payload: z.record(z.any()).optional().describe("Full plan payload (alternative to planId)"),
182
+ },
183
+ _meta: {
184
+ ui: { visibility: ["model", "app"] },
185
+ },
186
+ }, async (args) => {
187
+ try {
188
+ const { planId, payload } = args;
189
+ // Load plan from store or use inline payload
190
+ let planData;
191
+ if (planId) {
192
+ const localPlan = await store.get(planId);
193
+ planData = localPlan.plan;
194
+ }
195
+ else if (payload) {
196
+ planData = payload;
197
+ }
198
+ else {
199
+ return toCallToolResult({ error: true, message: "Provide either planId or payload." }, true);
200
+ }
201
+ // Scrub PII before cloud call
202
+ const { payload: scrubbed, removedFields } = scrubPII(planData);
203
+ if (!validateNoPII(scrubbed)) {
204
+ return toCallToolResult({
205
+ error: true,
206
+ message: "PII validation failed — payload may still contain personal information. Please review plan data.",
207
+ }, true);
208
+ }
209
+ if (removedFields.length > 0) {
210
+ console.log(`[local] PII scrubber removed ${removedFields.length} field(s): ${removedFields.join(", ")}`);
211
+ }
212
+ // Call cloud compute
213
+ const result = await computeClient.forecast(scrubbed);
214
+ if (result.error) {
215
+ // Try cached result
216
+ if (planId) {
217
+ const cached = await store.getCachedForecast(planId);
218
+ if (cached) {
219
+ return toCallToolResult({
220
+ ...cached,
221
+ _cached: true,
222
+ _cacheNote: "Cloud compute unavailable — showing cached forecast result.",
223
+ _cloudError: result.message,
224
+ });
225
+ }
226
+ }
227
+ return toCallToolResult({ error: true, message: result.message }, true);
228
+ }
229
+ // Cache result for offline access
230
+ if (planId && result.data) {
231
+ await store.cacheForecast(planId, result.data).catch((e) => {
232
+ console.warn("[local] Failed to cache forecast:", e);
233
+ });
234
+ }
235
+ return toCallToolResult(result.data);
236
+ }
237
+ catch (e) {
238
+ return toCallToolResult({ error: true, message: `Forecast failed: ${e instanceof Error ? e.message : String(e)}` }, true);
239
+ }
240
+ });
241
+ // ── 7. run_scenario (local → cloud compute) ────────────────────────
242
+ registerAppTool(server, "run_scenario", {
243
+ title: "Run What-If Scenario",
244
+ description: `Runs a what-if scenario comparing a baseline plan against modified assumptions.
245
+ Your plan data stays local — only PII-free inputs are sent to the cloud.
246
+
247
+ USE THIS TOOL WHEN THE USER:
248
+ - Asks "What if I retire at 62 instead of 65?"
249
+ - Wants to compare scenarios
250
+ - Wants to stress-test against historical market events`,
251
+ inputSchema: {
252
+ plan: z.record(z.any()).describe("Baseline plan object"),
253
+ label: z.string().optional().describe("Scenario label"),
254
+ planId: z.string().optional().describe("UUID of saved plan for caching"),
255
+ overrides: z.record(z.any()).optional().describe("Scenario overrides"),
256
+ },
257
+ _meta: {
258
+ ui: { visibility: ["model", "app"] },
259
+ },
260
+ }, async (args) => {
261
+ try {
262
+ const { plan, label, planId, overrides } = args;
263
+ // Scrub PII from the plan portion
264
+ const { payload: scrubbedPlan } = scrubPII(plan);
265
+ if (!validateNoPII(scrubbedPlan)) {
266
+ return toCallToolResult({ error: true, message: "PII validation failed on scenario plan." }, true);
267
+ }
268
+ const scenarioPayload = {
269
+ plan: scrubbedPlan,
270
+ label,
271
+ overrides,
272
+ };
273
+ const result = await computeClient.scenario(scenarioPayload);
274
+ if (result.error) {
275
+ return toCallToolResult({ error: true, message: result.message }, true);
276
+ }
277
+ // Cache scenario result
278
+ if (planId && result.data) {
279
+ await store
280
+ .cacheScenario(planId, label ?? "untitled", result.data)
281
+ .catch((e) => {
282
+ console.warn("[local] Failed to cache scenario:", e);
283
+ });
284
+ }
285
+ return toCallToolResult(result.data);
286
+ }
287
+ catch (e) {
288
+ return toCallToolResult({ error: true, message: `Scenario failed: ${e instanceof Error ? e.message : String(e)}` }, true);
289
+ }
290
+ });
291
+ // ── 8. coach (local → cloud compute) ───────────────────────────────
292
+ registerAppTool(server, "coach", {
293
+ title: "Retirement Coach",
294
+ description: `AI-powered retirement coaching guidance. Your conversation context is sent to the cloud for AI processing, but plan data is scrubbed of PII first.
295
+
296
+ USE THIS TOOL WHEN THE USER:
297
+ - Asks about retirement concepts
298
+ - Wants personalized guidance
299
+ - Has open-ended financial questions`,
300
+ inputSchema: {
301
+ content: z.string().describe("Coaching question or request"),
302
+ planId: z.string().optional().describe("UUID of plan for context"),
303
+ },
304
+ _meta: {
305
+ ui: { visibility: ["model", "app"] },
306
+ },
307
+ }, async (args) => {
308
+ try {
309
+ const { content, planId } = args;
310
+ const coachPayload = { content };
311
+ // Add plan context if available (PII-scrubbed)
312
+ if (planId) {
313
+ try {
314
+ const localPlan = await store.get(planId);
315
+ const { payload: scrubbedPlan } = scrubPII(localPlan.plan);
316
+ coachPayload.planContext = scrubbedPlan;
317
+ }
318
+ catch {
319
+ // Plan not found — continue without context
320
+ }
321
+ }
322
+ const result = await computeClient.coach(coachPayload);
323
+ if (result.error) {
324
+ return toCallToolResult({ error: true, message: result.message }, true);
325
+ }
326
+ return toCallToolResult(result.data);
327
+ }
328
+ catch (e) {
329
+ return toCallToolResult({ error: true, message: `Coach failed: ${e instanceof Error ? e.message : String(e)}` }, true);
330
+ }
331
+ });
332
+ // ── 9. status (local-only) ─────────────────────────────────────────
333
+ registerAppTool(server, "nestpilot_status", {
334
+ title: "NestPilot Status",
335
+ description: `Shows the status of your local NestPilot installation including plan count, data directory, cloud connectivity, and disk usage.`,
336
+ inputSchema: {},
337
+ _meta: {
338
+ ui: { visibility: ["model", "app"] },
339
+ },
340
+ }, async () => {
341
+ try {
342
+ const plans = await store.list();
343
+ const cloudReachable = await computeClient.healthCheck();
344
+ return toCallToolResult({
345
+ mode: "local",
346
+ dataDir: store["dataDir"],
347
+ planCount: plans.length,
348
+ cloudReachable,
349
+ cloudStatus: cloudReachable ? "connected" : "offline (cached results available)",
350
+ message: `NestPilot local mode: ${plans.length} plan(s), cloud ${cloudReachable ? "connected" : "offline"}.`,
351
+ });
352
+ }
353
+ catch (e) {
354
+ return toCallToolResult({ error: true, message: `Status check failed: ${e instanceof Error ? e.message : String(e)}` }, true);
355
+ }
356
+ });
357
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared MCP tool helpers — response formatting, backend proxy,
3
+ * contract validation, policy gates, and provenance annotation.
4
+ *
5
+ * FEAT-0056: Adds contract lookup, policy evaluation, and provenance
6
+ * envelope to the tool dispatch pipeline.
7
+ *
8
+ * @feature FEAT-0056
9
+ */
10
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
11
+ import { type UserRole } from "../src/policy/policy-engine.js";
12
+ /** Per-request authentication context threaded through the server factory. */
13
+ export interface AuthContext {
14
+ userId: string;
15
+ bearerToken?: string;
16
+ }
17
+ /**
18
+ * Wraps any value into a CallToolResult with a single text content block.
19
+ */
20
+ export declare function toCallToolResult(payload: unknown, isError?: boolean): CallToolResult;
21
+ export interface DispatchOptions {
22
+ /** Tool name for contract lookup and policy check. */
23
+ toolName?: string;
24
+ /** Actor/user ID for policy evaluation. */
25
+ actorId?: string;
26
+ /** User role for policy evaluation. */
27
+ role?: UserRole;
28
+ /** Auth context for forwarding JWT / user identity to backend. */
29
+ authCtx?: AuthContext;
30
+ }
31
+ /**
32
+ * POSTs to a NestPilot backend endpoint and returns a CallToolResult.
33
+ * For plan endpoints (/api/plan/*), normalizes enum type fields to lowercase.
34
+ */
35
+ export declare function proxyPostTool(endpoint: string, args: Record<string, unknown>, opts?: DispatchOptions): Promise<CallToolResult>;
36
+ /**
37
+ * GETs from a NestPilot backend endpoint and returns a CallToolResult.
38
+ */
39
+ export declare function proxyGetTool(endpoint: string, params?: Record<string, string>, opts?: DispatchOptions): Promise<CallToolResult>;
40
+ /**
41
+ * PUTs to a NestPilot backend endpoint and returns a CallToolResult.
42
+ * For plan endpoints (/api/plan/*), normalizes enum type fields to lowercase.
43
+ */
44
+ export declare function proxyPutTool(endpoint: string, args: Record<string, unknown>, opts?: DispatchOptions): Promise<CallToolResult>;
45
+ /**
46
+ * DELETEs from a NestPilot backend endpoint and returns a CallToolResult.
47
+ */
48
+ export declare function proxyDeleteTool(endpoint: string, opts?: DispatchOptions): Promise<CallToolResult>;
49
+ /**
50
+ * PATCHes to a NestPilot backend endpoint and returns a CallToolResult.
51
+ */
52
+ export declare function proxyPatchTool(endpoint: string, args: Record<string, unknown>, opts?: DispatchOptions): Promise<CallToolResult>;
@@ -0,0 +1,177 @@
1
+ import { nestpilotClient } from "../nestpilot-client.js";
2
+ import { lookupContract, } from "../src/contracts/tool-contract-registry.js";
3
+ import { annotateWithProvenance } from "../src/contracts/provenance.js";
4
+ import { evaluatePolicy, } from "../src/policy/policy-engine.js";
5
+ // ── Response helpers ─────────────────────────────────────────────────────
6
+ /**
7
+ * Wraps any value into a CallToolResult with a single text content block.
8
+ */
9
+ export function toCallToolResult(payload, isError = false) {
10
+ return {
11
+ isError,
12
+ content: [
13
+ {
14
+ type: "text",
15
+ text: JSON.stringify(payload),
16
+ },
17
+ ],
18
+ };
19
+ }
20
+ // ── Payload normalization ────────────────────────────────────────────────
21
+ /**
22
+ * Normalizes plan payloads before sending to the backend.
23
+ * The backend uses @JsonCreator with exact lowercase matching for
24
+ * AccountType (traditional, roth, taxable, hsa, cash) and
25
+ * IncomeType (salary, social_security, pension, annuity, rental, other).
26
+ * AI models sometimes send UPPERCASE variants, so we defensively lowercase them.
27
+ */
28
+ function normalizePlanPayload(args) {
29
+ const clone = structuredClone(args);
30
+ // Normalize accounts[].type
31
+ if (Array.isArray(clone.accounts)) {
32
+ for (const account of clone.accounts) {
33
+ if (account && typeof account === "object" && "type" in account) {
34
+ const a = account;
35
+ if (typeof a.type === "string") {
36
+ a.type = a.type.toLowerCase();
37
+ }
38
+ }
39
+ }
40
+ }
41
+ // Normalize incomeStreams[].type
42
+ if (Array.isArray(clone.incomeStreams)) {
43
+ for (const stream of clone.incomeStreams) {
44
+ if (stream && typeof stream === "object" && "type" in stream) {
45
+ const s = stream;
46
+ if (typeof s.type === "string") {
47
+ s.type = s.type.toLowerCase();
48
+ }
49
+ }
50
+ }
51
+ }
52
+ return clone;
53
+ }
54
+ // ── Policy denial response ───────────────────────────────────────────────
55
+ function policyDenialResult(decision) {
56
+ return toCallToolResult({
57
+ error: true,
58
+ code: 403,
59
+ message: `Policy denied: ${decision.reasonCodes.join(", ")}`,
60
+ policyDecision: decision,
61
+ }, true);
62
+ }
63
+ // ── Auth header builder ─────────────────────────────────────────────────
64
+ function buildAuthHeaders(authCtx) {
65
+ const headers = {};
66
+ if (authCtx?.bearerToken) {
67
+ headers["Authorization"] = `Bearer ${authCtx.bearerToken}`;
68
+ }
69
+ if (authCtx?.userId) {
70
+ headers["X-User-ID"] = authCtx.userId;
71
+ }
72
+ return headers;
73
+ }
74
+ function runPipeline(opts) {
75
+ let contract;
76
+ if (opts.toolName) {
77
+ const lookup = lookupContract(opts.toolName);
78
+ if (lookup.found) {
79
+ contract = lookup.contract;
80
+ }
81
+ }
82
+ if (contract) {
83
+ const policyCtx = {
84
+ actorId: opts.actorId ?? opts.authCtx?.userId ?? "anonymous",
85
+ role: opts.role ?? (opts.authCtx?.bearerToken ? "authenticated" : "anonymous"),
86
+ toolName: opts.toolName,
87
+ };
88
+ const decision = evaluatePolicy(policyCtx);
89
+ if (decision.decision === "deny") {
90
+ return { denied: policyDenialResult(decision) };
91
+ }
92
+ }
93
+ return { contract };
94
+ }
95
+ function wrapResult(result, contract) {
96
+ if (result.error) {
97
+ return toCallToolResult({
98
+ error: true,
99
+ code: result.code,
100
+ message: result.message ?? "Backend call failed",
101
+ }, true);
102
+ }
103
+ if (contract) {
104
+ const envelope = annotateWithProvenance(result, contract.toolName, contract.contractVersion);
105
+ return toCallToolResult(envelope);
106
+ }
107
+ return toCallToolResult(result);
108
+ }
109
+ // ── Backend proxy methods ────────────────────────────────────────────────
110
+ /**
111
+ * POSTs to a NestPilot backend endpoint and returns a CallToolResult.
112
+ * For plan endpoints (/api/plan/*), normalizes enum type fields to lowercase.
113
+ */
114
+ export async function proxyPostTool(endpoint, args, opts = {}) {
115
+ const { contract, denied } = runPipeline(opts);
116
+ if (denied)
117
+ return denied;
118
+ const payload = endpoint.startsWith("/api/plan")
119
+ ? normalizePlanPayload(args)
120
+ : args;
121
+ const result = await nestpilotClient.post(endpoint, payload, {
122
+ headers: buildAuthHeaders(opts.authCtx),
123
+ });
124
+ return wrapResult(result, contract);
125
+ }
126
+ /**
127
+ * GETs from a NestPilot backend endpoint and returns a CallToolResult.
128
+ */
129
+ export async function proxyGetTool(endpoint, params, opts = {}) {
130
+ const { contract, denied } = runPipeline(opts);
131
+ if (denied)
132
+ return denied;
133
+ const result = await nestpilotClient.get(endpoint, params, {
134
+ headers: buildAuthHeaders(opts.authCtx),
135
+ });
136
+ return wrapResult(result, contract);
137
+ }
138
+ /**
139
+ * PUTs to a NestPilot backend endpoint and returns a CallToolResult.
140
+ * For plan endpoints (/api/plan/*), normalizes enum type fields to lowercase.
141
+ */
142
+ export async function proxyPutTool(endpoint, args, opts = {}) {
143
+ const { contract, denied } = runPipeline(opts);
144
+ if (denied)
145
+ return denied;
146
+ const payload = endpoint.startsWith("/api/plan")
147
+ ? normalizePlanPayload(args)
148
+ : args;
149
+ const result = await nestpilotClient.put(endpoint, payload, {
150
+ headers: buildAuthHeaders(opts.authCtx),
151
+ });
152
+ return wrapResult(result, contract);
153
+ }
154
+ /**
155
+ * DELETEs from a NestPilot backend endpoint and returns a CallToolResult.
156
+ */
157
+ export async function proxyDeleteTool(endpoint, opts = {}) {
158
+ const { contract, denied } = runPipeline(opts);
159
+ if (denied)
160
+ return denied;
161
+ const result = await nestpilotClient.del(endpoint, {
162
+ headers: buildAuthHeaders(opts.authCtx),
163
+ });
164
+ return wrapResult(result, contract);
165
+ }
166
+ /**
167
+ * PATCHes to a NestPilot backend endpoint and returns a CallToolResult.
168
+ */
169
+ export async function proxyPatchTool(endpoint, args, opts = {}) {
170
+ const { contract, denied } = runPipeline(opts);
171
+ if (denied)
172
+ return denied;
173
+ const result = await nestpilotClient.patch(endpoint, args, {
174
+ headers: buildAuthHeaders(opts.authCtx),
175
+ });
176
+ return wrapResult(result, contract);
177
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type AuthContext } from "./mcp-helpers.js";
3
+ export declare function registerMedicareTools(server: McpServer, authCtx?: AuthContext): void;