@savvly/mcp-server 1.0.5 → 1.0.19

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/dist/cli.js CHANGED
@@ -21704,7 +21704,915 @@ var PRODUCT_COMPARISON = {
21704
21704
  var VALID_PRODUCT_TYPES = COMPARISONS.map((c) => c.product_type);
21705
21705
 
21706
21706
  // ../../src/mcp/version.ts
21707
- var SERVER_VERSION = "1.0.5";
21707
+ var SERVER_VERSION = "1.0.19";
21708
+
21709
+ // ../../src/mcp/ui/payout-chart.ts
21710
+ var PAYOUT_UI_URI = "ui://savvly/payout-chart.html";
21711
+ var PAYOUT_CHART_HTML = `<!DOCTYPE html>
21712
+ <html lang="en">
21713
+ <head>
21714
+ <meta charset="UTF-8" />
21715
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
21716
+ <title>Savvly payout projection</title>
21717
+ <style>
21718
+ html,body { margin:0; padding:0; background:transparent;
21719
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:#111827; }
21720
+ body.dark { color:#e5e7eb; }
21721
+ .wrap { padding:12px 14px; }
21722
+ h1 { font-size:15px; font-weight:600; margin:0 0 2px; }
21723
+ .legend { font-size:12px; display:flex; gap:16px; margin:0 0 6px; align-items:center; flex-wrap:wrap; }
21724
+ .legend span { display:inline-flex; align-items:center; gap:6px; }
21725
+ .sw { width:11px; height:11px; border-radius:2px; display:inline-block; }
21726
+ /* Editable assumptions form \u2014 left column; chart fills the same height on the right */
21727
+ .layout { display:flex; gap:14px; align-items:flex-start; margin:8px 0 4px; }
21728
+ .chartwrap { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; }
21729
+ /* chartwrap height is set in JS (render) to the sidebar height; #chart flex-grows
21730
+ to fill the space below the legend, and the SVG fills #chart. The SVG is drawn
21731
+ at #chart's measured px size (viewBox == box) so it maps 1:1 (crisp). */
21732
+ #chart { flex:1 1 auto; min-height:170px; display:flex; }
21733
+ #chart svg { width:100%; height:100%; display:block; }
21734
+ .controls { flex:0 0 180px; display:flex; flex-direction:column; gap:17px;
21735
+ padding:13px 12px; border:1px solid rgba(0,0,0,.09); border-radius:8px; background:rgba(52,120,235,.04); }
21736
+ body.dark .controls { border-color:rgba(255,255,255,.14); background:rgba(52,120,235,.08); }
21737
+ .controls:empty { display:none; }
21738
+ .controls.busy { opacity:.5; pointer-events:none; }
21739
+ /* The mode fields render inside #fldwrap, so the gap must live here too (the
21740
+ parent .controls gap only spaces toggle \u2194 fields \u2194 button). Same value so
21741
+ every input label gets equal padding above it. */
21742
+ #fldwrap { display:flex; flex-direction:column; gap:17px; }
21743
+ /* Narrow widgets (e.g. a tight side panel): stack the form above the chart. */
21744
+ @media (max-width:430px){ .layout{flex-direction:column;} .controls{flex:0 0 auto;} #chart{min-height:0; flex:0 0 auto;} #chart svg{height:auto;} }
21745
+ .fld { display:flex; flex-direction:column; gap:3px; min-width:0; }
21746
+ .fld label { font-size:11px; opacity:.75; font-weight:500; }
21747
+ .fld input { font:inherit; font-size:13px; padding:5px 8px; border-radius:6px; box-sizing:border-box; width:100%;
21748
+ border:1.5px solid rgba(52,120,235,.3); background:#fff; color:#111827; }
21749
+ body.dark .fld input { background:#1f2937; color:#e5e7eb; border-color:rgba(52,120,235,.5); }
21750
+ .fld input:focus { outline:none; border-color:#3478eb; box-shadow:0 0 0 3px rgba(52,120,235,.18); }
21751
+ /* Secondary selector \u2014 deliberately lighter than the solid-blue Update button:
21752
+ tinted "on" state, smaller + lighter weight, thinner border. */
21753
+ .seg { display:flex; flex-direction:column; gap:5px; }
21754
+ .seg button { font:inherit; font-size:11.5px; font-weight:500; padding:5px 8px; border-radius:6px; cursor:pointer;
21755
+ border:1px solid rgba(52,120,235,.25); background:#fff; color:#6b7280; text-align:center; }
21756
+ body.dark .seg button { background:#1f2937; color:#9ca3af; border-color:rgba(255,255,255,.18); }
21757
+ .seg button.on { background:rgba(52,120,235,.12); color:#3478eb; border-color:rgba(52,120,235,.55); font-weight:600; }
21758
+ body.dark .seg button.on { background:rgba(52,120,235,.22); color:#9bbcf2; border-color:rgba(52,120,235,.6); }
21759
+ .seg button:focus { outline:none; box-shadow:0 0 0 3px rgba(52,120,235,.18); }
21760
+ .fld.act { justify-content:flex-end; gap:0; }
21761
+ button#go { font:inherit; font-size:12.5px; font-weight:600; padding:6px 12px; border-radius:6px; cursor:pointer;
21762
+ border:1.5px solid #3478eb; background:#3478eb; color:#fff; width:100%; }
21763
+ button#go:hover { background:#2f6cd6; }
21764
+ button#go:focus { outline:none; box-shadow:0 0 0 3px rgba(52,120,235,.25); }
21765
+ .status { font-size:11px; color:#3478eb; min-height:0; }
21766
+ .assume { font-size:11.5px; opacity:.85; margin:12px 0 0; line-height:1.5; }
21767
+ .assume:empty { display:none; }
21768
+ .disc { font-size:10.5px; opacity:.7; margin:8px 0 0; line-height:1.45; }
21769
+ .disc a { color:#3478eb; }
21770
+ svg { width:100%; height:auto; display:block; }
21771
+ .empty { font-size:13px; opacity:.6; padding:24px 0; }
21772
+ </style>
21773
+ </head>
21774
+ <body>
21775
+ <div class="wrap">
21776
+ <h1>Savvly Payout Projection</h1>
21777
+ <div class="layout">
21778
+ <form class="controls" id="frm"></form>
21779
+ <div class="chartwrap">
21780
+ <div class="legend">
21781
+ <span><i class="sw" style="background:#3478eb"></i>With Savvly</span>
21782
+ <span><i class="sw" style="background:#c7c3c1"></i>Market Alone</span>
21783
+ </div>
21784
+ <div id="chart"><p class="empty">Loading projection\u2026</p></div>
21785
+ </div>
21786
+ </div>
21787
+ <p class="assume" id="assume"></p>
21788
+ <p class="disc" id="disc"></p>
21789
+ </div>
21790
+ <script>
21791
+ (function(){
21792
+ "use strict";
21793
+ var SAVVLY = "#3478eb", BASE = "#c7c3c1";
21794
+ var lastSC = null, lastChartW = -1;
21795
+
21796
+ function post(msg){ try { window.parent.postMessage(msg, "*"); } catch(e){} }
21797
+ function usd(n){ if(n==null||isNaN(n)) return "n/a";
21798
+ return new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",maximumFractionDigits:0}).format(n); }
21799
+ // $0 / $750K / $1.3M \u2014 thousands end in K, millions in M (matches the retirement chart).
21800
+ function usdK(n){ if(n==null||isNaN(n)) return ""; if(n===0) return "$0";
21801
+ if(n>=1e6) return "$"+(n/1e6).toFixed(1).replace(/\\.0$/,"")+"M";
21802
+ if(n>=1e3) return "$"+(n/1e3).toFixed(1).replace(/\\.0$/,"")+"K";
21803
+ return "$"+Math.round(n); }
21804
+ function esc(s){ return String(s==null?"":s).replace(/[&<>]/g,function(c){return c==="&"?"&amp;":c==="<"?"&lt;":"&gt;";}); }
21805
+ // Rounded y-axis step (~6 gridlines) at a 1/2/2.5/5 x 10^n value, so ticks land
21806
+ // on equal round units ($1M, $2M, \u2026) instead of yMax/5 (which gave $828K, $1.7M\u2026).
21807
+ function niceStep(range, ticks){
21808
+ if(!(range>0)) return 10000;
21809
+ var raw=range/ticks, mag=Math.pow(10,Math.floor(Math.log10(raw))), n=raw/mag;
21810
+ return (n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10)*mag;
21811
+ }
21812
+ // Bar value labels: whole numbers only \u2014 no decimals on the thousands ($586K,
21813
+ // not $585.9K). Millions keep one decimal so adjacent bars stay distinguishable.
21814
+ function usdBar(n){ if(n==null||isNaN(n)) return "$0"; if(n===0) return "$0";
21815
+ if(n>=1e6) return "$"+(n/1e6).toFixed(1).replace(/\\.0$/,"")+"M";
21816
+ if(n>=1e3) return "$"+Math.round(n/1e3)+"K";
21817
+ return "$"+Math.round(n); }
21818
+
21819
+ // One plain-English sentence naming the inputs that AREN'T editable in the
21820
+ // form. Lump-sum exposes every input, so it has none. Monthly's only hidden
21821
+ // input is the optional annual contribution increase (shown when present).
21822
+ function assumptions(inputs){
21823
+ if(inputs.monthly_amount!=null && inputs.installment_increase_percentage!=null)
21824
+ return "Also assumes a "+inputs.installment_increase_percentage+"% annual increase in monthly contributions.";
21825
+ return "";
21826
+ }
21827
+
21828
+ // ---- editable assumptions form (re-runs the tool via the host) ----
21829
+ // Two shapes share this widget: lump-sum (funding_amount) and monthly
21830
+ // (monthly_amount + contribution_years). Monthly exposes "Retirement age" and
21831
+ // derives contribution_years = retirement_age \u2212 current_age on submit (the
21832
+ // schema has no retirement_age field). __ret is the synthetic derived field.
21833
+ var LUMP=[
21834
+ ["pf_age","current_age","Current Age",'min="25" max="79" step="any"'],
21835
+ ["pf_amount","funding_amount","Investment Amount ($)",'min="100" step="any"'],
21836
+ ["pf_return","average_return","S&P 500 Return (%)",'min="1" max="15" step="any"']
21837
+ ];
21838
+ var MONTHLY=[
21839
+ ["pf_age","current_age","Current Age",'min="25" max="79" step="any"'],
21840
+ ["pf_monthly","monthly_amount","Monthly Deposit ($)",'min="10" step="any"'],
21841
+ ["pf_ret","__ret","Retirement Age",'min="26" max="80" step="any"'],
21842
+ ["pf_return","average_return","S&P 500 Return (%)",'min="1" max="15" step="any"']
21843
+ ];
21844
+ // Projection-type toggle (the first input). Switches which tool re-runs.
21845
+ var SEG=[["monthly","Monthly Deposit"],["lumpsum","Single Upfront Deposit"]];
21846
+ var mode=null, fields=null, formBuilt=false, statusTimer=null, pinH=0;
21847
+ function elv(id){ return document.getElementById(id); }
21848
+ function numOr(id,fb){ var el=elv(id); if(!el) return fb; var v=el.value; if(v===""||v==null) return fb; var n=Number(v); return isFinite(n)?n:fb; }
21849
+ function detectMode(inputs){ if(!inputs) return null; if(inputs.funding_amount!=null) return "lumpsum"; if(inputs.monthly_amount!=null) return "monthly"; return null; }
21850
+ function setStatus(msg,sticky){ var s=elv("status"); if(!s) return; s.textContent=msg||"";
21851
+ if(statusTimer){ clearTimeout(statusTimer); statusTimer=null; }
21852
+ if(msg && !sticky){ statusTimer=setTimeout(function(){ var s2=elv("status"); if(s2) s2.textContent=""; },2600); } }
21853
+ function setBusy(b){ var f=elv("frm"); if(f){ if(b) f.classList.add("busy"); else f.classList.remove("busy"); } var g=elv("go"); if(g) g.disabled=!!b; }
21854
+ // (Re)build the mode-specific fields into #fldwrap and sync the toggle highlight.
21855
+ function renderFields(){
21856
+ fields = mode==="lumpsum" ? LUMP : MONTHLY;
21857
+ var w=elv("fldwrap"); if(!w) return; var h="",i;
21858
+ for(i=0;i<fields.length;i++){ var sp=fields[i];
21859
+ h+='<div class="fld"><label for="'+sp[0]+'">'+sp[2]+'</label><input id="'+sp[0]+'" type="number" '+sp[3]+' inputmode="numeric" /></div>'; }
21860
+ w.innerHTML=h;
21861
+ var bs=document.querySelectorAll("#seg button"),j;
21862
+ for(j=0;j<bs.length;j++){ if(bs[j].getAttribute("data-m")===mode) bs[j].className="on"; else bs[j].className=""; }
21863
+ }
21864
+ function switchMode(m){
21865
+ if(!m || m===mode) return;
21866
+ // carry the shared assumptions across the switch; default the mode-specific ones
21867
+ var age=Math.round(numOr("pf_age",40)), ret=numOr("pf_return",8);
21868
+ mode=m; renderFields();
21869
+ if(elv("pf_age")) elv("pf_age").value=age;
21870
+ if(elv("pf_return")) elv("pf_return").value=ret;
21871
+ if(mode==="lumpsum"){ if(elv("pf_amount")) elv("pf_amount").value=10000; }
21872
+ else { if(elv("pf_monthly")) elv("pf_monthly").value=100; if(elv("pf_ret")) elv("pf_ret").value=age+27; }
21873
+ recalc();
21874
+ }
21875
+ function buildForm(inputs){
21876
+ if(formBuilt) return; mode=detectMode(inputs); if(!mode) return;
21877
+ var f=elv("frm"); if(!f) return; var seg="",i;
21878
+ for(i=0;i<SEG.length;i++){ seg+='<button type="button" data-m="'+SEG[i][0]+'">'+SEG[i][1]+'</button>'; }
21879
+ f.innerHTML='<div class="fld"><label>Projection Type</label><div class="seg" id="seg">'+seg+'</div></div>'
21880
+ +'<div id="fldwrap"></div>'
21881
+ +'<div class="fld act"><button type="submit" id="go">Update Projection</button><span class="status" id="status"></span></div>';
21882
+ formBuilt=true;
21883
+ renderFields();
21884
+ reserveHeight();
21885
+ f.addEventListener("submit",function(e){ e.preventDefault(); recalc(); });
21886
+ var bs=document.querySelectorAll("#seg button"),j;
21887
+ for(j=0;j<bs.length;j++){ (function(btn){ btn.addEventListener("click",function(){ switchMode(btn.getAttribute("data-m")); }); })(bs[j]); }
21888
+ }
21889
+ // Pin the sidebar to the TALLER monthly layout so the toggle doesn't resize it
21890
+ // (the chart matches via align-items:stretch). Measure by SUMMING children +
21891
+ // gaps + padding so we get the form's true content height \u2014 not the height the
21892
+ // flex row has stretched #frm to (which baked in phantom slack). Removing the
21893
+ // Withdrawal Age field naturally shrinks this measured height.
21894
+ function reserveHeight(){
21895
+ var f=elv("frm"); if(!f) return;
21896
+ var save=mode;
21897
+ f.style.height=""; f.style.minHeight=""; // clear so offsetHeight is the CONTENT height
21898
+ mode="monthly"; renderFields();
21899
+ var h=f.offsetHeight; // true monthly content (.layout is align-items:flex-start \u2192 no stretch)
21900
+ mode=save; renderFields();
21901
+ // Pin to the tallest (monthly) content so the sidebar doesn't resize on toggle;
21902
+ // lump-sum shows the one-field difference as space below the button. minHeight
21903
+ // (not height) so it can never clip if a font/zoom makes content slightly taller.
21904
+ if(h>0){ pinH=Math.ceil(h); f.style.minHeight=pinH+"px"; }
21905
+ }
21906
+ function fillForm(inputs){ if(!fields) return; for(var i=0;i<fields.length;i++){ var sp=fields[i],el=elv(sp[0]);
21907
+ if(!el||el===document.activeElement) continue;
21908
+ if(sp[1]==="__ret"){ if(inputs.current_age!=null && inputs.contribution_years!=null) el.value=inputs.current_age+inputs.contribution_years; }
21909
+ else { var v=inputs[sp[1]]; if(v!=null) el.value=v; } } }
21910
+ // Build the tool args from the FORM (not lastSC) so a mode switch sends correct
21911
+ // args even though the prior result was the other tool. Falls back to lastSC /
21912
+ // schema defaults for any blank field.
21913
+ function recalc(){
21914
+ if(!mode) return;
21915
+ var base=(lastSC && lastSC.inputs) || {};
21916
+ var age=Math.max(25,Math.min(79,Math.round(numOr("pf_age", base.current_age!=null?base.current_age:40))));
21917
+ var ret=Math.max(1,Math.min(15,Math.round(numOr("pf_return", base.average_return!=null?base.average_return:8))));
21918
+ var args, tool;
21919
+ if(mode==="lumpsum"){
21920
+ args={ current_age:age, average_return:ret,
21921
+ funding_amount:Math.max(100,numOr("pf_amount", base.funding_amount!=null?base.funding_amount:10000)) };
21922
+ tool="project_savvly_lumpsum";
21923
+ } else {
21924
+ var retage=Math.round(numOr("pf_ret", age+(base.contribution_years!=null?base.contribution_years:27)));
21925
+ args={ current_age:age, average_return:ret,
21926
+ monthly_amount:Math.max(10,numOr("pf_monthly", base.monthly_amount!=null?base.monthly_amount:100)),
21927
+ contribution_years:Math.max(1,Math.min(55, retage-age)) }; // schema has years, not retirement age
21928
+ if(base.installment_increase_percentage!=null) args.installment_increase_percentage=base.installment_increase_percentage;
21929
+ tool="project_savvly_monthly";
21930
+ }
21931
+ // Withdrawal age has no UI field \u2014 carry whatever the tool last used (its
21932
+ // default when unset) so re-runs don't reset it.
21933
+ if(base.withdrawal_age!=null) args.withdrawal_age=base.withdrawal_age;
21934
+ setBusy(true); // dim the form while re-running; no "Updating\u2026" label
21935
+ callTool(tool,args).then(function(res){
21936
+ var nsc=(res && res.structuredContent) || parseContent(res);
21937
+ if(nsc && nsc.result){ lastSC=nsc; render(); setStatus(""); }
21938
+ else setStatus("No update returned");
21939
+ }).catch(function(){ setStatus("Update failed \u2014 try again"); }).then(function(){ setBusy(false); });
21940
+ }
21941
+
21942
+ function render(){
21943
+ var sc = lastSC;
21944
+ if(!sc || !sc.result || !Array.isArray(sc.result.payout_age_dependent_values)) return;
21945
+ var rows = sc.result.payout_age_dependent_values;
21946
+ document.getElementById("assume").textContent = assumptions(sc.inputs || {});
21947
+ buildForm(sc.inputs);
21948
+ if(sc.inputs) fillForm(sc.inputs);
21949
+
21950
+ var dark = document.body.classList.contains("dark");
21951
+ var FG = dark ? "#e5e7eb" : "#111827";
21952
+ var MUTED = dark ? "#9ca3af" : "#6b7280";
21953
+ var GRID = dark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)";
21954
+
21955
+ // Draw at the chart container's ACTUAL pixel size so the SVG maps 1:1 \u2014 it
21956
+ // fills the (fixed) sidebar height AND keeps text crisp at the stated px
21957
+ // sizes (no preserveAspectRatio stretch, which was squishing the numbers).
21958
+ var box = document.getElementById("chart");
21959
+ var lay = document.querySelector(".layout");
21960
+ var cw = document.querySelector(".chartwrap");
21961
+ var rowMode = !!(lay && getComputedStyle(lay).flexDirection==="row");
21962
+ // Pin the whole visualization block (legend + chart) to the sidebar's ACTUAL
21963
+ // rendered height (robust even if pinH and the real content drift), then draw
21964
+ // the SVG at the chart area's ACTUAL pixel size so it maps 1:1 (crisp, fills).
21965
+ var frm = elv("frm");
21966
+ if(cw) cw.style.height = (rowMode && frm) ? Math.round(frm.getBoundingClientRect().height)+"px" : "";
21967
+ var W = Math.max(360, Math.round((box && box.clientWidth) || 560));
21968
+ var H = Math.max(170, Math.round((box && box.clientHeight) || (rowMode ? 320 : W*0.62)));
21969
+ lastChartW = W;
21970
+ var padL=62, padR=18, padT=18, padB=46;
21971
+ var plotW=W-padL-padR, plotH=H-padT-padB;
21972
+ var peak=0, i;
21973
+ for(i=0;i<rows.length;i++){ if((rows[i].savvly_payout_upper||0)>peak) peak=rows[i].savvly_payout_upper; }
21974
+ var yStep=niceStep(peak,6); // rounded, equal tick spacing ($1M, $200K, \u2026)
21975
+ var yMax=Math.max(peak*1.08, yStep); // headroom above the tallest bar for its value label
21976
+ function y(v){ return padT+plotH-((v||0)/yMax)*plotH; }
21977
+ // Tight blue+gray PAIR (small intra-gap) with a wider gap BETWEEN age groups,
21978
+ // so each age reads as one group. between-group (~0.36\xB7groupW) >> intra (0.12).
21979
+ var n=rows.length, groupW=plotW/Math.max(n,1), barW=groupW*0.26, gap=groupW*0.12;
21980
+
21981
+ var s=[];
21982
+ s.push('<svg width="'+W+'" height="'+H+'" viewBox="0 0 '+W+' '+H+'" role="img" aria-label="Savvly payouts by milestone age">');
21983
+ for(var g=0; g<=yMax+1; g+=yStep){
21984
+ var gy=y(g);
21985
+ s.push('<line x1="'+padL+'" y1="'+gy.toFixed(1)+'" x2="'+(W-padR)+'" y2="'+gy.toFixed(1)+'" stroke="'+GRID+'"/>');
21986
+ s.push('<text x="'+(padL-8)+'" y="'+(gy+4).toFixed(1)+'" font-size="12" fill="'+MUTED+'" text-anchor="end">'+usdK(g)+'</text>');
21987
+ }
21988
+ for(var j=0;j<n;j++){
21989
+ var r=rows[j];
21990
+ var cx=padL+j*groupW+groupW/2;
21991
+ var x1=cx-barW-gap/2, x2=cx+gap/2;
21992
+ var sv=r.savvly_payout_upper||0, mk=r.payout_market_alone_upper||0;
21993
+ s.push('<rect x="'+x1.toFixed(1)+'" y="'+y(sv).toFixed(1)+'" width="'+barW.toFixed(1)+'" height="'+(plotH-(y(sv)-padT)).toFixed(1)+'" rx="2" fill="'+SAVVLY+'"/>');
21994
+ s.push('<rect x="'+x2.toFixed(1)+'" y="'+y(mk).toFixed(1)+'" width="'+barW.toFixed(1)+'" height="'+(plotH-(y(mk)-padT)).toFixed(1)+'" rx="2" fill="'+BASE+'"/>');
21995
+ s.push('<text x="'+(x1+barW/2).toFixed(1)+'" y="'+(y(sv)-7).toFixed(1)+'" font-size="12" fill="'+SAVVLY+'" text-anchor="middle" font-weight="600">'+usdBar(sv)+'</text>');
21996
+ s.push('<text x="'+(x2+barW/2).toFixed(1)+'" y="'+(y(mk)-7).toFixed(1)+'" font-size="12" fill="'+MUTED+'" text-anchor="middle">'+usdBar(mk)+'</text>');
21997
+ s.push('<text x="'+cx.toFixed(1)+'" y="'+(H-padB+24).toFixed(1)+'" font-size="13" fill="'+FG+'" text-anchor="middle" font-weight="600">Age '+r.payout_age+'</text>');
21998
+ }
21999
+ s.push('</svg>');
22000
+ document.getElementById("chart").innerHTML = s.join("");
22001
+
22002
+ var d = sc.disclosure || {};
22003
+ if(d.text){
22004
+ document.getElementById("disc").innerHTML =
22005
+ esc(d.text) + (d.url ? ' <a href="'+esc(d.url)+'" target="_blank" rel="noopener">'+esc(d.url)+'</a>' : "");
22006
+ }
22007
+ }
22008
+
22009
+ // app -> host tool call (promise over JSON-RPC), used by the editable form to
22010
+ // re-run the projection live. Same pattern as compare-chart.ts.
22011
+ var rpcId=2, pending={};
22012
+ function callTool(name,args){ return new Promise(function(resolve,reject){
22013
+ var id=rpcId++; pending[id]={resolve:resolve,reject:reject};
22014
+ post({ jsonrpc:"2.0", id:id, method:"tools/call", params:{ name:name, arguments:args } });
22015
+ setTimeout(function(){ if(pending[id]){ delete pending[id]; reject(new Error("timeout")); } },20000);
22016
+ }); }
22017
+ function parseContent(res){ try{ var c=res&&res.content&&res.content[0]; return c&&c.text?JSON.parse(c.text):null; }catch(e){ return null; } }
22018
+
22019
+ var INIT_ID = 1;
22020
+ window.addEventListener("message", function(ev){
22021
+ var m = ev && ev.data; if(!m || m.jsonrpc!=="2.0") return;
22022
+ if(m.id===INIT_ID && m.result){
22023
+ var ctx = m.result.hostContext || {};
22024
+ if(ctx.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22025
+ post({ jsonrpc:"2.0", method:"ui/notifications/initialized", params:{} });
22026
+ render();
22027
+ return;
22028
+ }
22029
+ if(m.id===INIT_ID && m.error){ var le=document.getElementById("chart"); if(le) le.innerHTML='<p class="empty">Chart unavailable (init error '+((m.error&&m.error.code)||"")+').</p>'; return; }
22030
+ if(m.id && (m.result||m.error) && pending[m.id]){
22031
+ var p=pending[m.id]; delete pending[m.id];
22032
+ if(m.error) p.reject(new Error((m.error&&m.error.message)||"tool error")); else p.resolve(m.result);
22033
+ return;
22034
+ }
22035
+ if(!m.id && m.method==="ui/notifications/tool-result"){
22036
+ lastSC = (m.params || {}).structuredContent || null;
22037
+ render();
22038
+ }
22039
+ if(!m.id && m.method==="ui/notifications/host-context-changed"){
22040
+ var c2 = (m.params || {}).hostContext || {};
22041
+ if(c2.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22042
+ render();
22043
+ }
22044
+ });
22045
+
22046
+ // Report content size so the host sizes the iframe to fit (flexible maxHeight
22047
+ // mode) \u2014 without this the host uses a tiny default height and the widget scrolls.
22048
+ function sendSize(){ try{ post({ jsonrpc:"2.0", method:"ui/notifications/size-changed", params:{ width:Math.ceil(document.documentElement.scrollWidth), height:Math.ceil(document.documentElement.scrollHeight) } }); }catch(e){} }
22049
+ if(window.ResizeObserver){ new ResizeObserver(function(){ sendSize();
22050
+ // Width changed \u2192 redraw so the chart re-measures and stays 1:1 (crisp).
22051
+ var box=document.getElementById("chart");
22052
+ if(box && lastSC && Math.abs((box.clientWidth||0)-lastChartW)>2) render();
22053
+ }).observe(document.body); }
22054
+
22055
+ post({ jsonrpc:"2.0", id:INIT_ID, method:"ui/initialize", params:{
22056
+ protocolVersion:"2026-01-26",
22057
+ appInfo:{ name:"savvly-payout-chart", version:"1.0.0" },
22058
+ capabilities:{},
22059
+ appCapabilities:{ availableDisplayModes:["inline"] }
22060
+ }});
22061
+ })();
22062
+ </script>
22063
+ </body>
22064
+ </html>`;
22065
+
22066
+ // ../../src/mcp/ui/retirement-chart.ts
22067
+ var RETIREMENT_UI_URI = "ui://savvly/retirement-chart.html";
22068
+ var MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
22069
+ var RETIREMENT_CHART_HTML = `<!DOCTYPE html>
22070
+ <html lang="en">
22071
+ <head>
22072
+ <meta charset="UTF-8" />
22073
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22074
+ <title>Savvly retirement projection</title>
22075
+ <style>
22076
+ html,body { margin:0; padding:0; background:transparent;
22077
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:#111827; }
22078
+ body.dark { color:#e5e7eb; }
22079
+ .wrap { padding:12px 14px; }
22080
+ h1 { font-size:15px; font-weight:600; margin:0 0 2px; }
22081
+ .legend { font-size:12px; display:flex; gap:16px; margin:0 0 6px; align-items:center; flex-wrap:wrap; }
22082
+ .legend span { display:inline-flex; align-items:center; gap:6px; }
22083
+ .sw { width:20px; height:11px; border-radius:2px; display:inline-block; }
22084
+ /* Editable assumptions form \u2014 left column; chart fills the same height on the right */
22085
+ .layout { display:flex; gap:14px; align-items:flex-start; margin:8px 0 4px; }
22086
+ .chartwrap { flex:1 1 auto; min-width:0; }
22087
+ .controls { flex:0 0 180px; display:flex; flex-direction:column; gap:9px;
22088
+ padding:11px 12px; border:1px solid rgba(0,0,0,.09); border-radius:8px; background:rgba(52,120,235,.04); }
22089
+ body.dark .controls { border-color:rgba(255,255,255,.14); background:rgba(52,120,235,.08); }
22090
+ .controls.busy { opacity:.5; pointer-events:none; }
22091
+ /* Narrow widgets (e.g. a tight side panel): stack the form above the chart. */
22092
+ @media (max-width:430px){ .layout{flex-direction:column;} .controls{flex:0 0 auto;} }
22093
+ .fld { display:flex; flex-direction:column; gap:3px; min-width:0; }
22094
+ .fld label { font-size:11px; opacity:.75; font-weight:500; }
22095
+ .fld input { font:inherit; font-size:13px; padding:5px 8px; border-radius:6px; box-sizing:border-box; width:100%;
22096
+ border:1.5px solid rgba(52,120,235,.3); background:#fff; color:#111827; }
22097
+ body.dark .fld input { background:#1f2937; color:#e5e7eb; border-color:rgba(52,120,235,.5); }
22098
+ .fld input:focus { outline:none; border-color:#3478eb; box-shadow:0 0 0 3px rgba(52,120,235,.18); }
22099
+ .fld.act { justify-content:flex-end; gap:0; }
22100
+ button#go { font:inherit; font-size:12.5px; font-weight:600; padding:6px 12px; border-radius:6px; cursor:pointer;
22101
+ border:1.5px solid #3478eb; background:#3478eb; color:#fff; width:100%; }
22102
+ button#go:hover { background:#2f6cd6; }
22103
+ button#go:focus { outline:none; box-shadow:0 0 0 3px rgba(52,120,235,.25); }
22104
+ .status { font-size:11px; color:#3478eb; min-height:0; }
22105
+ .disc { font-size:10.5px; opacity:.7; margin:12px 0 0; line-height:1.45; }
22106
+ .disc a { color:#3478eb; }
22107
+ svg { width:100%; height:auto; display:block; }
22108
+ .empty { font-size:13px; opacity:.6; padding:24px 0; }
22109
+ </style>
22110
+ </head>
22111
+ <body>
22112
+ <div class="wrap">
22113
+ <h1>Retirement Forecast</h1>
22114
+ <div class="layout">
22115
+ <form class="controls" id="frm">
22116
+ <div class="fld"><label for="f_age">Current Age</label><input id="f_age" type="number" min="25" max="79" step="any" inputmode="numeric" /></div>
22117
+ <div class="fld"><label for="f_savings">Current Savings ($)</label><input id="f_savings" type="number" min="0" step="any" inputmode="numeric" /></div>
22118
+ <div class="fld"><label for="f_contrib">Monthly Contribution ($)</label><input id="f_contrib" type="number" min="0" step="any" inputmode="numeric" /></div>
22119
+ <div class="fld"><label for="f_paycheck">Retirement Paycheck ($/mo)</label><input id="f_paycheck" type="number" min="0" step="any" inputmode="numeric" /></div>
22120
+ <div class="fld"><label for="f_other">Other Income ($/mo \xB7 Optional)</label><input id="f_other" type="number" min="0" step="any" inputmode="numeric" placeholder="0" /></div>
22121
+ <div class="fld act"><button type="submit" id="go">Update Forecast</button><span class="status" id="status"></span></div>
22122
+ </form>
22123
+ <div class="chartwrap">
22124
+ <div class="legend">
22125
+ <span><i class="sw" style="background:#c7c3c1"></i>Current Retirement Forecast</span>
22126
+ <span><i class="sw" style="background:#3478eb"></i>With a Longevity Solution in the Portfolio</span>
22127
+ </div>
22128
+ <div id="chart"><p class="empty">Loading projection\u2026</p></div>
22129
+ </div>
22130
+ </div>
22131
+ <p class="disc" id="disc"></p>
22132
+ </div>
22133
+ <script>
22134
+ (function(){
22135
+ "use strict";
22136
+ var SAVVLY = "#3478eb";
22137
+ var lastSC = null;
22138
+
22139
+ function post(msg){ try { window.parent.postMessage(msg, "*"); } catch(e){} }
22140
+ function esc(s){ return String(s==null?"":s).replace(/[&<>]/g,function(c){return c==="&"?"&amp;":c==="<"?"&lt;":"&gt;";}); }
22141
+ // Mirrors estimator_web's nFormatter: $ 0 / $250K / $1M / $1.3M / $1.5M.
22142
+ function nfmt(num){ if(num>=1e9) return (num/1e9).toFixed(1).replace(/\\.0$/,"")+"G";
22143
+ if(num>=1e6) return (num/1e6).toFixed(1).replace(/\\.0$/,"")+"M";
22144
+ if(num>=1e3) return (num/1e3).toFixed(1).replace(/\\.0$/,"")+"K";
22145
+ return Number(num).toFixed(1).replace(/\\.0$/,""); }
22146
+ function axisUsd(n){ return n===0 ? "$ 0" : "$"+nfmt(n); }
22147
+ // Rounded y-axis step (~6 gridlines) at a 1/2/2.5/5 x 10^n value, so the axis
22148
+ // stays legible whether the peak is $2M or $30M. A fixed step crammed 100+
22149
+ // overlapping labels once monthly contributions pushed the peak high.
22150
+ function niceStep(range, ticks){
22151
+ if(!(range>0)) return 10000;
22152
+ var raw=range/ticks, mag=Math.pow(10,Math.floor(Math.log10(raw))), n=raw/mag;
22153
+ return (n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10)*mag;
22154
+ }
22155
+
22156
+ // ---- editable assumptions form (re-runs the tool via the host) ----
22157
+ var FIELDS=[
22158
+ ["f_age","current_age"],["f_savings","current_retirement_savings"],
22159
+ ["f_contrib","monthly_contribution"],["f_paycheck","monthly_paycheck"],
22160
+ ["f_other","other_retirement_income"]
22161
+ ];
22162
+ var formBuilt=false, statusTimer=null;
22163
+ function elv(id){ return document.getElementById(id); }
22164
+ function numOr(id,fb){ var el=elv(id); if(!el) return fb; var v=el.value; if(v===""||v==null) return fb; var n=Number(v); return isFinite(n)?n:fb; }
22165
+ function setStatus(msg,sticky){ var s=elv("status"); if(!s) return; s.textContent=msg||"";
22166
+ if(statusTimer){ clearTimeout(statusTimer); statusTimer=null; }
22167
+ if(msg && !sticky){ statusTimer=setTimeout(function(){ var s2=elv("status"); if(s2) s2.textContent=""; },2600); } }
22168
+ function setBusy(b){ var f=elv("frm"); if(f){ if(b) f.classList.add("busy"); else f.classList.remove("busy"); } var g=elv("go"); if(g) g.disabled=!!b; }
22169
+ function buildForm(){ if(formBuilt) return; var f=elv("frm"); if(!f) return; formBuilt=true;
22170
+ f.addEventListener("submit",function(e){ e.preventDefault(); recalc(); }); }
22171
+ // Prefill inputs from the current tool result; never clobber the field the user is editing.
22172
+ function fillForm(inputs){ for(var i=0;i<FIELDS.length;i++){ var el=elv(FIELDS[i][0]); if(!el||el===document.activeElement) continue;
22173
+ var v=inputs[FIELDS[i][1]]; if(v!=null) el.value=v; } }
22174
+ function recalc(){
22175
+ var base=(lastSC && lastSC.inputs) || null; if(!base) return;
22176
+ // Copy ALL 13 inputs forward so untouched assumptions (retirement_age,
22177
+ // returns, % in Savvly, \u2026) are preserved, then override the 5 editable ones.
22178
+ var merged={},k; for(k in base){ if(Object.prototype.hasOwnProperty.call(base,k)) merged[k]=base[k]; }
22179
+ merged.current_age=Math.max(25,Math.min(79,Math.round(numOr("f_age",base.current_age))));
22180
+ merged.current_retirement_savings=Math.max(0,numOr("f_savings",base.current_retirement_savings));
22181
+ merged.monthly_contribution=Math.max(0,numOr("f_contrib",base.monthly_contribution));
22182
+ merged.monthly_paycheck=Math.max(0,numOr("f_paycheck",base.monthly_paycheck));
22183
+ merged.other_retirement_income=Math.max(0,numOr("f_other",0)); // optional \u2192 blank means none
22184
+ setBusy(true); // dim the form while re-running; no "Updating\u2026" label
22185
+ callTool("project_retirement_with_savvly",merged).then(function(res){
22186
+ var nsc=(res && res.structuredContent) || parseContent(res);
22187
+ if(nsc && nsc.result){ lastSC=nsc; render(); setStatus(""); }
22188
+ else setStatus("No update returned");
22189
+ }).catch(function(){ setStatus("Update failed \u2014 try again"); }).then(function(){ setBusy(false); });
22190
+ }
22191
+
22192
+ function render(){
22193
+ var sc = lastSC;
22194
+ if(!sc || !sc.result || !Array.isArray(sc.result.age_dependent_values)) return;
22195
+ var rows = sc.result.age_dependent_values;
22196
+ if(!rows.length) return;
22197
+ buildForm();
22198
+ if(sc.inputs) fillForm(sc.inputs);
22199
+
22200
+ var dark = document.body.classList.contains("dark");
22201
+ var FG = dark ? "#e5e7eb" : "#111827";
22202
+ var MUTED = dark ? "#9ca3af" : "#6b7280";
22203
+ var GRID = dark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.08)";
22204
+
22205
+ var GRAYFILL = "#c7c3c1"; // "Current Retirement Forecast" (estimator_web GRAPH_NEGATIVE_DATA)
22206
+ var WITH = "retirement_savings_with_savvly_in_the_portfolio";
22207
+ var WO = "retirement_savings_without_savvly_in_the_portfolio";
22208
+
22209
+ var W=640, H=360, padL=64, padR=16, padT=16, padB=40;
22210
+ var plotW=W-padL-padR, plotH=H-padT-padB;
22211
+ var i, peak=0;
22212
+ for(i=0;i<rows.length;i++){ var a=rows[i][WITH]||0, b=rows[i][WO]||0; if(a>peak)peak=a; if(b>peak)peak=b; }
22213
+ var yStep = niceStep(peak, 6); // rounded tick spacing, scales with the peak
22214
+ var yMax = Math.max(peak * 1.04, yStep); // a little headroom above the data
22215
+ var xMin = rows[0].age, xMax = rows[rows.length-1].age, xDen=(xMax-xMin)||1;
22216
+ function x(age){ return padL + (age-xMin)/xDen*plotW; }
22217
+ function y(v){ return padT+plotH-((v||0)/yMax)*plotH; }
22218
+ var baseY=(padT+plotH).toFixed(1);
22219
+ function P(f){ var a=[],k; for(k=0;k<rows.length;k++) a.push({x:x(rows[k].age),y:y(rows[k][f])}); return a; }
22220
+ function cl(v){ return v<padT?padT:(v>padT+plotH?padT+plotH:v); }
22221
+ // Chart.js splineCurve (cardinal spline) control points at tension 0.4.
22222
+ function ctrl(pts){ var c=[],i; for(i=0;i<pts.length;i++){ var p0=pts[i-1]||pts[i],p1=pts[i],p2=pts[i+1]||pts[i];
22223
+ var d01=Math.hypot(p1.x-p0.x,p1.y-p0.y),d12=Math.hypot(p2.x-p1.x,p2.y-p1.y),sm=d01+d12,s01=sm?d01/sm:0,s12=sm?d12/sm:0,fa=0.4*s01,fb=0.4*s12;
22224
+ c.push({pX:p1.x-fa*(p2.x-p0.x),pY:cl(p1.y-fa*(p2.y-p0.y)),nX:p1.x+fb*(p2.x-p0.x),nY:cl(p1.y+fb*(p2.y-p0.y))}); } return c; }
22225
+ function smooth(pts){ if(pts.length<2) return ""; var c=ctrl(pts),d="M"+pts[0].x.toFixed(1)+","+pts[0].y.toFixed(1),i;
22226
+ for(i=0;i<pts.length-1;i++){ d+=" C"+c[i].nX.toFixed(1)+","+c[i].nY.toFixed(1)+" "+c[i+1].pX.toFixed(1)+","+c[i+1].pY.toFixed(1)+" "+pts[i+1].x.toFixed(1)+","+pts[i+1].y.toFixed(1); } return d; }
22227
+ function areaD(pts){ return smooth(pts)+" L"+pts[pts.length-1].x.toFixed(1)+","+baseY+" L"+pts[0].x.toFixed(1)+","+baseY+" Z"; }
22228
+
22229
+ var s=[];
22230
+ s.push('<svg viewBox="0 0 '+W+' '+H+'" role="img" aria-label="Retirement forecast with vs without a longevity solution">');
22231
+ for(var g=0; g<=yMax+1; g+=yStep){ var gy=y(g);
22232
+ s.push('<line x1="'+padL+'" y1="'+gy.toFixed(1)+'" x2="'+(W-padR)+'" y2="'+gy.toFixed(1)+'" stroke="'+GRID+'"/>');
22233
+ s.push('<text x="'+(padL-8)+'" y="'+(gy+4).toFixed(1)+'" font-size="10" fill="'+MUTED+'" text-anchor="end">'+axisUsd(g)+'</text>');
22234
+ }
22235
+ var a0=Math.ceil(xMin/5)*5; for(var ax=a0; ax<=xMax; ax+=5){
22236
+ s.push('<text x="'+x(ax).toFixed(1)+'" y="'+(H-padB+18)+'" font-size="10" fill="'+FG+'" text-anchor="middle">'+ax+'</text>');
22237
+ }
22238
+ s.push('<text x="'+((padL+(W-padR))/2).toFixed(1)+'" y="'+(H-4)+'" font-size="11" fill="'+MUTED+'" text-anchor="middle">Age</text>');
22239
+ // Tension-0.4 spline-smoothed filled areas, matching the estimator_web
22240
+ // RetirementCalculator Chart.js config. Blue behind, gray on top (gray
22241
+ // accumulation hump reads through; Savvly milestone-payout spikes emerge in
22242
+ // blue as the traditional portfolio depletes); blue stroke traces with-Savvly.
22243
+ var PW=P(WITH), PO=P(WO);
22244
+ s.push('<path d="'+areaD(PW)+'" fill="'+SAVVLY+'"/>');
22245
+ s.push('<path d="'+areaD(PO)+'" fill="'+GRAYFILL+'"/>');
22246
+ s.push('<path d="'+smooth(PW)+'" fill="none" stroke="'+SAVVLY+'" stroke-width="2"/>');
22247
+ s.push('</svg>');
22248
+ document.getElementById("chart").innerHTML = s.join("");
22249
+
22250
+ var d = sc.disclosure || {};
22251
+ if(d.text){
22252
+ document.getElementById("disc").innerHTML =
22253
+ esc(d.text) + (d.url ? ' <a href="'+esc(d.url)+'" target="_blank" rel="noopener">'+esc(d.url)+'</a>' : "");
22254
+ }
22255
+ }
22256
+
22257
+ // app -> host tool call (promise over JSON-RPC), used by the editable form to
22258
+ // re-run the projection live. Same pattern as compare-chart.ts.
22259
+ var rpcId=2, pending={};
22260
+ function callTool(name,args){ return new Promise(function(resolve,reject){
22261
+ var id=rpcId++; pending[id]={resolve:resolve,reject:reject};
22262
+ post({ jsonrpc:"2.0", id:id, method:"tools/call", params:{ name:name, arguments:args } });
22263
+ setTimeout(function(){ if(pending[id]){ delete pending[id]; reject(new Error("timeout")); } },20000);
22264
+ }); }
22265
+ function parseContent(res){ try{ var c=res&&res.content&&res.content[0]; return c&&c.text?JSON.parse(c.text):null; }catch(e){ return null; } }
22266
+
22267
+ var INIT_ID = 1;
22268
+ window.addEventListener("message", function(ev){
22269
+ var m = ev && ev.data; if(!m || m.jsonrpc!=="2.0") return;
22270
+ if(m.id===INIT_ID && m.result){
22271
+ var ctx = m.result.hostContext || {};
22272
+ if(ctx.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22273
+ post({ jsonrpc:"2.0", method:"ui/notifications/initialized", params:{} });
22274
+ buildForm();
22275
+ render();
22276
+ return;
22277
+ }
22278
+ if(m.id===INIT_ID && m.error){ var le=document.getElementById("chart"); if(le) le.innerHTML='<p class="empty">Chart unavailable (init error '+((m.error&&m.error.code)||"")+').</p>'; return; }
22279
+ if(m.id && (m.result||m.error) && pending[m.id]){
22280
+ var p=pending[m.id]; delete pending[m.id];
22281
+ if(m.error) p.reject(new Error((m.error&&m.error.message)||"tool error")); else p.resolve(m.result);
22282
+ return;
22283
+ }
22284
+ if(!m.id && m.method==="ui/notifications/tool-result"){
22285
+ lastSC = (m.params || {}).structuredContent || null;
22286
+ render();
22287
+ }
22288
+ if(!m.id && m.method==="ui/notifications/host-context-changed"){
22289
+ var c2 = (m.params || {}).hostContext || {};
22290
+ if(c2.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22291
+ render();
22292
+ }
22293
+ });
22294
+
22295
+ // Report content size so the host sizes the iframe to fit (flexible maxHeight
22296
+ // mode) \u2014 without this the host uses a tiny default height and the widget scrolls.
22297
+ function sendSize(){ try{ post({ jsonrpc:"2.0", method:"ui/notifications/size-changed", params:{ width:Math.ceil(document.documentElement.scrollWidth), height:Math.ceil(document.documentElement.scrollHeight) } }); }catch(e){} }
22298
+ if(window.ResizeObserver){ new ResizeObserver(function(){ sendSize(); }).observe(document.body); }
22299
+
22300
+ post({ jsonrpc:"2.0", id:INIT_ID, method:"ui/initialize", params:{
22301
+ protocolVersion:"2026-01-26",
22302
+ appInfo:{ name:"savvly-retirement-chart", version:"1.0.0" },
22303
+ capabilities:{},
22304
+ appCapabilities:{ availableDisplayModes:["inline"] }
22305
+ }});
22306
+ })();
22307
+ </script>
22308
+ </body>
22309
+ </html>`;
22310
+
22311
+ // ../../src/mcp/ui/compare-chart.ts
22312
+ var COMPARE_UI_URI = "ui://savvly/compare-matrix.html";
22313
+ var COMPARE_MATRIX_HTML = `<!DOCTYPE html>
22314
+ <html lang="en">
22315
+ <head>
22316
+ <meta charset="UTF-8" />
22317
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22318
+ <title>Savvly comparison</title>
22319
+ <style>
22320
+ :root { --fg:#111827; --muted:#6b7280; --line:rgba(0,0,0,.09); --seg:rgba(0,0,0,.12); --savvly:#3478eb; }
22321
+ body.dark { --fg:#e5e7eb; --muted:#9ca3af; --line:rgba(255,255,255,.14); --seg:rgba(255,255,255,.18); }
22322
+ html,body { margin:0; padding:0; background:transparent; color:var(--fg);
22323
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
22324
+ .wrap { padding:12px 14px; }
22325
+ .row-top { display:flex; justify-content:space-between; align-items:center; gap:10px; margin:0 0 10px; flex-wrap:wrap; }
22326
+ h1 { font-size:15px; font-weight:600; margin:0; }
22327
+ label.pick { font-size:12px; color:var(--muted); display:inline-flex; align-items:center; gap:6px; }
22328
+ select { font:inherit; font-size:12.5px; font-weight:600; padding:6px 28px 6px 10px; border-radius:6px;
22329
+ border:1.5px solid var(--savvly); background:rgba(52,120,235,0.08); color:var(--savvly); cursor:pointer;
22330
+ appearance:none; -webkit-appearance:none;
22331
+ background-image:linear-gradient(45deg,transparent 50%,var(--savvly) 50%),linear-gradient(135deg,var(--savvly) 50%,transparent 50%);
22332
+ background-position:right 13px center, right 8px center; background-size:5px 5px, 5px 5px; background-repeat:no-repeat; }
22333
+ select:hover, select:focus { outline:none; box-shadow:0 0 0 3px rgba(52,120,235,0.2); }
22334
+ table { width:100%; border-collapse:collapse; font-size:12.5px; }
22335
+ th,td { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line); vertical-align:top; }
22336
+ th.s { color:var(--savvly); }
22337
+ td.dim { color:var(--muted); white-space:nowrap; }
22338
+ .bars { display:inline-flex; gap:2px; vertical-align:middle; margin-right:6px; }
22339
+ .seg { width:9px; height:9px; border-radius:2px; display:inline-block; }
22340
+ .lab { font-size:11.5px; }
22341
+ .disc { font-size:10.5px; opacity:.7; margin-top:10px; line-height:1.45; }
22342
+ .disc a { color:var(--savvly); }
22343
+ .empty { font-size:13px; opacity:.6; padding:24px 0; }
22344
+ </style>
22345
+ </head>
22346
+ <body>
22347
+ <div class="wrap">
22348
+ <div class="row-top">
22349
+ <h1 id="title">Savvly comparison</h1>
22350
+ <label class="pick">Compare Savvly with <select id="alt" title="Change the product to compare"></select></label>
22351
+ </div>
22352
+ <div id="grid"><p class="empty">Loading comparison\u2026</p></div>
22353
+ <p class="disc" id="disc"></p>
22354
+ </div>
22355
+ <script>
22356
+ (function(){
22357
+ "use strict";
22358
+ var SAVVLY="#3478eb", BASE="#c7c3c1";
22359
+ var ALTS=[
22360
+ ["fixed_annuity","Fixed annuity"],["variable_annuity","Variable annuity"],
22361
+ ["indexed_annuity","Indexed annuity"],["target_date_fund","Target-date fund"],
22362
+ ["managed_payout_fund","Managed payout fund"],["social_security_delay","Delaying Social Security"],
22363
+ ["self_managed_drawdown","Self-managed drawdown"]
22364
+ ];
22365
+ var DIMS=[
22366
+ ["longevity_protection","Longevity protection","rating"],
22367
+ ["market_upside","Market upside","rating"],
22368
+ ["liquidity","Liquidity","rating"],
22369
+ ["portability","Portability","rating"],
22370
+ ["inflation_protection","Inflation protection","rating"],
22371
+ ["counterparty_risk","Counterparty risk (lower = better)","rating"],
22372
+ ["complexity","Complexity (lower = better)","rating"],
22373
+ ["fees","Fees","fees"],
22374
+ ["minimum_investment","Minimum investment","min"],
22375
+ ["death_benefit","Death benefit","text"],
22376
+ ["tax_treatment","Tax treatment","text"],
22377
+ ["regulatory_oversight","Regulatory oversight","text"]
22378
+ ];
22379
+ var savvly=null, current=null, disclaimer=null;
22380
+
22381
+ function post(msg){ try { window.parent.postMessage(msg,"*"); } catch(e){} }
22382
+ function esc(s){ return String(s==null?"":s).replace(/[&<>]/g,function(c){return c==="&"?"&amp;":c==="<"?"&lt;":"&gt;";}); }
22383
+ function bar(score,color){ var n=Math.max(0,Math.min(5,Math.round(score||0))),h="",k;
22384
+ for(k=0;k<5;k++){ h+='<i class="seg" style="background:'+(k<n?color:"var(--seg)")+'"></i>'; }
22385
+ return '<span class="bars">'+h+'</span>'; }
22386
+ function cell(val,type,color){
22387
+ if(val==null) return '<span class="lab" style="opacity:.5">\u2014</span>';
22388
+ if(type==="rating") return bar(val.score,color)+'<span class="lab">'+esc(val.label)+'</span>';
22389
+ if(type==="fees") return '<span class="lab">'+esc(val.range||val.summary||"")+'</span>';
22390
+ if(type==="min") return '<span class="lab">'+esc(val.amount!=null?val.amount:val)+'</span>';
22391
+ return '<span class="lab">'+esc(val)+'</span>';
22392
+ }
22393
+
22394
+ function ingest(sc){
22395
+ if(!sc) return;
22396
+ if(sc.savvly) savvly=sc.savvly;
22397
+ if(sc.metadata && sc.metadata.disclaimer) disclaimer=sc.metadata.disclaimer;
22398
+ if(sc.comparison) current=sc.comparison;
22399
+ else if(Array.isArray(sc.comparisons) && sc.comparisons.length){
22400
+ var want=document.getElementById("alt").value;
22401
+ current=sc.comparisons.filter(function(c){return c.product_type===want;})[0] || sc.comparisons[0];
22402
+ }
22403
+ }
22404
+
22405
+ function buildDropdown(){
22406
+ var sel=document.getElementById("alt"); if(sel.options.length) return;
22407
+ var html=""; for(var i=0;i<ALTS.length;i++){ html+='<option value="'+ALTS[i][0]+'">'+esc(ALTS[i][1])+'</option>'; }
22408
+ sel.innerHTML=html;
22409
+ sel.addEventListener("change",function(){
22410
+ callTool("compare_savvly_vs_alternative",{alternative:sel.value}).then(function(res){
22411
+ var nsc=(res&&res.structuredContent)||parseContent(res); if(nsc){ ingest(nsc); render(); }
22412
+ }).catch(function(){});
22413
+ });
22414
+ }
22415
+
22416
+ function render(){
22417
+ buildDropdown();
22418
+ if(!savvly || !current) return;
22419
+ if(current.product_type) document.getElementById("alt").value=current.product_type;
22420
+ var altName=current.display_name||current.product_type||"alternative";
22421
+ document.getElementById("title").textContent="Savvly vs "+altName;
22422
+ var ad=current.dimensions||{};
22423
+ var s=['<table><thead><tr><th>Dimension</th><th class="s">Savvly</th><th>'+esc(altName)+'</th></tr></thead><tbody>'];
22424
+ for(var i=0;i<DIMS.length;i++){
22425
+ var key=DIMS[i][0], label=DIMS[i][1], type=DIMS[i][2];
22426
+ s.push('<tr><td class="dim">'+esc(label)+'</td><td>'+cell(savvly[key],type,SAVVLY)+'</td><td>'+cell(ad[key],type,BASE)+'</td></tr>');
22427
+ }
22428
+ s.push('</tbody></table>');
22429
+ document.getElementById("grid").innerHTML=s.join("");
22430
+
22431
+ var url=disclaimer&&disclaimer.full_disclosures_url;
22432
+ var txt=(disclaimer&&disclaimer.summary)||"Comparison is illustrative. Ratings are Savvly's own assessment.";
22433
+ document.getElementById("disc").innerHTML=esc(txt)+(url?' <a href="'+esc(url)+'" target="_blank" rel="noopener">'+esc(url)+'</a>':"");
22434
+ }
22435
+
22436
+ // app -> host tool call (promise over JSON-RPC)
22437
+ var rpcId=2, pending={};
22438
+ function callTool(name,args){ return new Promise(function(resolve,reject){
22439
+ var id=rpcId++; pending[id]={resolve:resolve,reject:reject};
22440
+ post({jsonrpc:"2.0",id:id,method:"tools/call",params:{name:name,arguments:args}});
22441
+ setTimeout(function(){ if(pending[id]){ delete pending[id]; reject(new Error("timeout")); } },15000);
22442
+ }); }
22443
+ function parseContent(res){ try{ var c=res&&res.content&&res.content[0]; return c&&c.text?JSON.parse(c.text):null; }catch(e){ return null; } }
22444
+
22445
+ var INIT_ID=1;
22446
+ window.addEventListener("message",function(ev){
22447
+ var m=ev&&ev.data; if(!m||m.jsonrpc!=="2.0") return;
22448
+ if(m.id===INIT_ID&&m.result){
22449
+ var ctx=m.result.hostContext||{};
22450
+ if(ctx.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22451
+ post({ jsonrpc:"2.0", method:"ui/notifications/initialized", params:{} });
22452
+ render(); return;
22453
+ }
22454
+ if(m.id===INIT_ID && m.error){ var le=document.getElementById("grid"); if(le) le.innerHTML='<p class="empty">Comparison unavailable (init error '+((m.error&&m.error.code)||"")+').</p>'; return; }
22455
+ if(m.id&&(m.result||m.error)&&pending[m.id]){
22456
+ var p=pending[m.id]; delete pending[m.id];
22457
+ if(m.error) p.reject(new Error(m.error.message||"tool error")); else p.resolve(m.result);
22458
+ return;
22459
+ }
22460
+ if(!m.id&&m.method==="ui/notifications/tool-result"){ ingest((m.params||{}).structuredContent); render(); }
22461
+ if(!m.id&&m.method==="ui/notifications/host-context-changed"){
22462
+ var c2=(m.params||{}).hostContext||{};
22463
+ if(c2.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22464
+ render();
22465
+ }
22466
+ });
22467
+
22468
+ function sendSize(){ try{ post({jsonrpc:"2.0",method:"ui/notifications/size-changed",params:{width:Math.ceil(document.documentElement.scrollWidth),height:Math.ceil(document.documentElement.scrollHeight)}}); }catch(e){} }
22469
+ if(window.ResizeObserver){ new ResizeObserver(function(){ sendSize(); }).observe(document.body); }
22470
+
22471
+ post({jsonrpc:"2.0",id:INIT_ID,method:"ui/initialize",params:{
22472
+ protocolVersion:"2026-01-26",
22473
+ appInfo:{name:"savvly-compare-matrix",version:"1.0.0"},
22474
+ capabilities:{},
22475
+ appCapabilities:{availableDisplayModes:["inline"]}
22476
+ }});
22477
+ })();
22478
+ </script>
22479
+ </body>
22480
+ </html>`;
22481
+
22482
+ // ../../src/mcp/ui/product-card.ts
22483
+ var PRODUCT_UI_URI = "ui://savvly/product-card.html";
22484
+ var PRODUCT_CARD_HTML = `<!DOCTYPE html>
22485
+ <html lang="en">
22486
+ <head>
22487
+ <meta charset="UTF-8" />
22488
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
22489
+ <title>Savvly product overview</title>
22490
+ <style>
22491
+ :root { --fg:#111827; --muted:#6b7280; --line:rgba(0,0,0,.09); --card:rgba(0,0,0,.03); --savvly:#3478eb; }
22492
+ body.dark { --fg:#e5e7eb; --muted:#9ca3af; --line:rgba(255,255,255,.14); --card:rgba(255,255,255,.05); }
22493
+ html,body { margin:0; padding:0; background:transparent; color:var(--fg);
22494
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
22495
+ .wrap { padding:12px 14px; }
22496
+ h1 { font-size:16px; font-weight:600; margin:0 0 2px; }
22497
+ .tag { font-size:12.5px; color:var(--muted); margin:0 0 10px; }
22498
+ .grid { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:0 0 12px; }
22499
+ .fact { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:8px 10px; }
22500
+ .fact .k { font-size:10.5px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
22501
+ .fact .v { font-size:13px; margin-top:2px; }
22502
+ .acc { color:var(--savvly); font-weight:600; }
22503
+ h2 { font-size:12.5px; font-weight:600; margin:4px 0 6px; }
22504
+ svg { width:100%; height:auto; display:block; }
22505
+ .disc { font-size:10.5px; opacity:.7; margin-top:10px; line-height:1.45; }
22506
+ .disc a { color:var(--savvly); }
22507
+ .empty { font-size:13px; opacity:.6; padding:24px 0; }
22508
+ </style>
22509
+ </head>
22510
+ <body>
22511
+ <div class="wrap">
22512
+ <h1 id="name">Savvly</h1>
22513
+ <p class="tag" id="tag"></p>
22514
+ <div class="grid" id="facts"></div>
22515
+ <h2 id="psh" style="display:none">Payout schedule</h2>
22516
+ <div id="chart"></div>
22517
+ <p class="disc" id="disc"></p>
22518
+ <p class="empty" id="empty">Loading product info\u2026</p>
22519
+ </div>
22520
+ <script>
22521
+ (function(){
22522
+ "use strict";
22523
+ var SAVVLY="#3478eb";
22524
+ var lastSC=null;
22525
+ function post(msg){ try { window.parent.postMessage(msg,"*"); } catch(e){} }
22526
+ function esc(s){ return String(s==null?"":s).replace(/[&<>]/g,function(c){return c==="&"?"&amp;":c==="<"?"&lt;":"&gt;";}); }
22527
+ function fact(k,v){ if(v==null||v==="") return ""; return '<div class="fact"><div class="k">'+esc(k)+'</div><div class="v">'+v+'</div></div>'; }
22528
+
22529
+ function render(){
22530
+ var p=lastSC; if(!p||!p.name) return;
22531
+ document.getElementById("empty").style.display="none";
22532
+ document.getElementById("name").textContent=p.name;
22533
+ document.getElementById("tag").textContent=p.tagline||"";
22534
+
22535
+ var inv=p.investment||{}, fees=p.fees||{}, reg=p.regulatory||{};
22536
+ var minStr="";
22537
+ if(inv.minimum_monthly&&inv.minimum_monthly.amount!=null) minStr+="$"+inv.minimum_monthly.amount+"/mo";
22538
+ if(inv.minimum_lumpsum&&inv.minimum_lumpsum.amount!=null) minStr+=(minStr?" \xB7 ":"")+"$"+inv.minimum_lumpsum.amount+" lump";
22539
+ var feeStr="";
22540
+ if(fees.blended_range_bps&&fees.blended_range_bps.low!=null) feeStr=fees.blended_range_bps.low+"\u2013"+fees.blended_range_bps.high+" bps blended";
22541
+ var notIns=(reg.is_insurance===false&&reg.is_annuity===false)?"Not an annuity or insurance":"";
22542
+
22543
+ var g="";
22544
+ g+=fact("Underlying", inv.underlying_assets ? esc(inv.underlying_assets) : null);
22545
+ g+=fact("Minimum", minStr || null);
22546
+ g+=fact("Fees", feeStr ? esc(feeStr) : null);
22547
+ g+=fact("Structure", reg.type ? esc(reg.type) : null);
22548
+ g+=fact("Adviser", reg.adviser ? esc(reg.adviser) : null);
22549
+ g+=fact("Important", notIns ? '<span class="acc">'+esc(notIns)+'</span>' : null);
22550
+ document.getElementById("facts").innerHTML=g;
22551
+
22552
+ renderPayout(Array.isArray(p.payout_schedule)?p.payout_schedule:[]);
22553
+
22554
+ var d=p.disclaimers||{};
22555
+ var url=d.full_disclosures_url;
22556
+ var txt=d.chart_summary||d.projections||"";
22557
+ document.getElementById("disc").innerHTML=esc(txt)+(url?' <a href="'+esc(url)+'" target="_blank" rel="noopener">'+esc(url)+'</a>':"");
22558
+ }
22559
+
22560
+ function renderPayout(rows){
22561
+ if(!rows.length){ return; }
22562
+ document.getElementById("psh").style.display="";
22563
+ var dark=document.body.classList.contains("dark");
22564
+ var FG=dark?"#e5e7eb":"#111827", MUTED=dark?"#9ca3af":"#6b7280", GRID=dark?"rgba(255,255,255,0.12)":"rgba(0,0,0,0.08)";
22565
+ var W=560,H=210,padL=20,padR=14,padT=20,padB=34, plotW=W-padL-padR, plotH=H-padT-padB;
22566
+ var peak=0,i; for(i=0;i<rows.length;i++){ if((rows[i].percentage||0)>peak) peak=rows[i].percentage; }
22567
+ // Just enough top room (padT) for the value label above the tallest bar \u2014 no
22568
+ // extra yMax headroom, so the heading-to-bars gap stays tight.
22569
+ var yMax=Math.max(peak,0.1);
22570
+ function y(v){ return padT+plotH-((v||0)/yMax)*plotH; }
22571
+ var n=rows.length, groupW=plotW/Math.max(n,1), barW=groupW*0.5;
22572
+ var s=['<svg viewBox="0 0 '+W+' '+H+'" role="img" aria-label="Savvly payout schedule by age">'];
22573
+ s.push('<line x1="'+padL+'" y1="'+(padT+plotH).toFixed(1)+'" x2="'+(W-padR)+'" y2="'+(padT+plotH).toFixed(1)+'" stroke="'+GRID+'"/>');
22574
+ for(var j=0;j<n;j++){
22575
+ var r=rows[j], cx=padL+j*groupW+groupW/2, x=cx-barW/2;
22576
+ var pct=r.percentage||0, hh=plotH-(y(pct)-padT);
22577
+ s.push('<rect x="'+x.toFixed(1)+'" y="'+y(pct).toFixed(1)+'" width="'+barW.toFixed(1)+'" height="'+hh.toFixed(1)+'" rx="3" fill="'+SAVVLY+'"/>');
22578
+ s.push('<text x="'+cx.toFixed(1)+'" y="'+(y(pct)-5).toFixed(1)+'" font-size="11" fill="'+SAVVLY+'" text-anchor="middle" font-weight="600">'+Math.round(pct*100)+'%</text>');
22579
+ s.push('<text x="'+cx.toFixed(1)+'" y="'+(H-padB+18)+'" font-size="11" fill="'+FG+'" text-anchor="middle">Age '+esc(r.age)+'</text>');
22580
+ }
22581
+ s.push('</svg>');
22582
+ document.getElementById("chart").innerHTML=s.join("");
22583
+ }
22584
+
22585
+ var INIT_ID=1;
22586
+ window.addEventListener("message",function(ev){
22587
+ var m=ev&&ev.data; if(!m||m.jsonrpc!=="2.0") return;
22588
+ if(m.id===INIT_ID&&m.result){
22589
+ var ctx=m.result.hostContext||{};
22590
+ if(ctx.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22591
+ post({ jsonrpc:"2.0", method:"ui/notifications/initialized", params:{} });
22592
+ render(); return;
22593
+ }
22594
+ if(m.id===INIT_ID&&m.error){ var le=document.getElementById("empty"); if(le){le.style.display="";le.textContent="INIT ERROR "+(m.error.code||"")+": "+(m.error.message||JSON.stringify(m.error));} return; }
22595
+ if(!m.id&&m.method==="ui/notifications/tool-result"){ lastSC=(m.params||{}).structuredContent||null; render(); }
22596
+ if(!m.id&&m.method==="ui/notifications/host-context-changed"){
22597
+ var c2=(m.params||{}).hostContext||{};
22598
+ if(c2.theme==="dark") document.body.classList.add("dark"); else document.body.classList.remove("dark");
22599
+ render();
22600
+ }
22601
+ });
22602
+
22603
+ function sendSize(){ try{ post({jsonrpc:"2.0",method:"ui/notifications/size-changed",params:{width:Math.ceil(document.documentElement.scrollWidth),height:Math.ceil(document.documentElement.scrollHeight)}}); }catch(e){} }
22604
+ if(window.ResizeObserver){ new ResizeObserver(function(){ sendSize(); }).observe(document.body); }
22605
+
22606
+ post({jsonrpc:"2.0",id:INIT_ID,method:"ui/initialize",params:{
22607
+ protocolVersion:"2026-01-26",
22608
+ appInfo:{name:"savvly-product-card",version:"1.0.0"},
22609
+ capabilities:{},
22610
+ appCapabilities:{availableDisplayModes:["inline"]}
22611
+ }});
22612
+ })();
22613
+ </script>
22614
+ </body>
22615
+ </html>`;
21708
22616
 
21709
22617
  // ../../src/data/faq.ts
21710
22618
  var FAQ = [
@@ -22542,7 +23450,7 @@ var PayoutAgeRowSchema = external_exports.object({
22542
23450
  savvly_upside_upper: external_exports.number().describe(
22543
23451
  "USD. Upper bound for the incremental Savvly upside at this age. Tends to be large at 90/95 where the market alone portfolio is depleted."
22544
23452
  )
22545
- });
23453
+ }).passthrough();
22546
23454
  var PayoutEnvelopeSchema = external_exports.object({
22547
23455
  total_savvly_upside_lower: external_exports.number().describe(
22548
23456
  "USD. Lower bound of the cumulative incremental upside Savvly provides vs. investing in the market alone, summed across all milestone payout ages."
@@ -22580,7 +23488,7 @@ var PayoutEnvelopeSchema = external_exports.object({
22580
23488
  payout_age_dependent_values: external_exports.array(PayoutAgeRowSchema).describe(
22581
23489
  "Per-milestone-age breakdown of the projection. Typically 4 rows for ages 80, 85, 90, 95. Each row contains the with Savvly payout, the market alone counterfactual, and the incremental Savvly upside for that age, each with `_lower`/`_upper` bounds."
22582
23490
  )
22583
- });
23491
+ }).passthrough();
22584
23492
  var RetirementAgeRowSchema = external_exports.object({
22585
23493
  age: external_exports.number().int().describe("Age (years) the row reports."),
22586
23494
  retirement_savings_without_savvly_in_the_portfolio: external_exports.number().describe(
@@ -22595,7 +23503,7 @@ var RetirementAgeRowSchema = external_exports.object({
22595
23503
  savvly_value_alone: external_exports.number().describe(
22596
23504
  "USD. Standalone value attributable to the Savvly allocation at this age, excluding the rest of the retirement portfolio."
22597
23505
  )
22598
- });
23506
+ }).passthrough();
22599
23507
  var RetirementEnvelopeSchema = external_exports.object({
22600
23508
  gap_score: external_exports.number().describe(
22601
23509
  "Server-computed score summarizing how well the projected retirement savings meet the desired monthly paycheck across the planning horizon."
@@ -22610,7 +23518,7 @@ var RetirementEnvelopeSchema = external_exports.object({
22610
23518
  age_dependent_values: external_exports.array(RetirementAgeRowSchema).describe(
22611
23519
  "Per-age timeline across the planning horizon (current_age \u2192 life_expectancy). One row per year."
22612
23520
  )
22613
- });
23521
+ }).passthrough();
22614
23522
  var TopLevelDisclosureSchema = external_exports.object({
22615
23523
  required: external_exports.literal(true).describe(
22616
23524
  "Always true. Signals to the calling AI client that the `text` and `url` fields MUST be displayed to the end user whenever any number from this response is shown. Required by SEC Marketing Rule and FINRA Rule 2210."
@@ -22657,6 +23565,38 @@ var ProjectionSummarySchema = external_exports.object({
22657
23565
  "Human-readable English sentence summarizing the projection. Always ends with 'Payouts are not guarantees. See full disclosures at <url>.' \u2014 display the URL verbatim alongside any figures."
22658
23566
  )
22659
23567
  });
23568
+ var ChartSpecSchema = external_exports.object({
23569
+ type: external_exports.enum(["area", "line", "bar"]).describe("Chart type the client should render."),
23570
+ reference: external_exports.string().optional().describe("Canonical Savvly visualization this chart mirrors."),
23571
+ x_axis: external_exports.object({ field: external_exports.string(), label: external_exports.string() }).passthrough().describe("Per-row field mapped to the x-axis, plus its display label."),
23572
+ y_axis: external_exports.object({ label: external_exports.string(), format: external_exports.string() }).passthrough().describe("Y-axis label and value format (e.g. 'currency_usd')."),
23573
+ series: external_exports.array(
23574
+ external_exports.object({
23575
+ field: external_exports.string().describe("Per-row field plotted by this series."),
23576
+ label: external_exports.string().describe("Legend label for this series."),
23577
+ color: external_exports.string().describe(
23578
+ "Hex color for this series. The series representing Savvly is always #3478eb (the Savvly brand blue)."
23579
+ )
23580
+ }).passthrough()
23581
+ ).describe(
23582
+ "One entry per plotted series \u2014 typically Savvly vs. the baseline / market-alone counterfactual."
23583
+ ),
23584
+ tooltip: external_exports.record(external_exports.string()).optional().describe("Optional `field -> human label` map for chart tooltips."),
23585
+ rendering_notes: external_exports.array(external_exports.string()).describe("Plain-English rendering guidance for the client.")
23586
+ }).passthrough();
23587
+ var VisualizationSchema = external_exports.object({
23588
+ recommended: external_exports.literal(true).describe(
23589
+ "Always true. Signals to the calling AI client that it SHOULD render `chart` whenever it can display a graph."
23590
+ ),
23591
+ instruction: external_exports.string().describe(
23592
+ "Plain-English instruction telling the client to render `chart` from the per-row data in `result`, color the Savvly series #3478eb, and show the `disclosure` alongside the chart."
23593
+ ),
23594
+ chart: ChartSpecSchema.describe(
23595
+ "Machine-readable chart spec: type, axes, series (with brand colors), and rendering notes."
23596
+ )
23597
+ }).passthrough().describe(
23598
+ "VISUALIZATION RECOMMENDED: render `chart` from the per-row data in `result` (Savvly series colored #3478eb) whenever the consuming surface can display a graph."
23599
+ );
22660
23600
  var ProjectionResponseSchema = external_exports.object({
22661
23601
  inputs: external_exports.record(external_exports.unknown()).describe("Echo of the validated input arguments passed to the tool."),
22662
23602
  result: PayoutEnvelopeSchema.describe(
@@ -22665,15 +23605,21 @@ var ProjectionResponseSchema = external_exports.object({
22665
23605
  summary: ProjectionSummarySchema.describe(
22666
23606
  "Convenience summary including a human-readable narrative."
22667
23607
  ),
23608
+ visualization: VisualizationSchema.optional().describe(
23609
+ "Recommended chart for this projection \u2014 a grouped bar chart of the milestone payouts in `result.payout_age_dependent_values` (Savvly vs market alone). Render it when the surface can display a graph."
23610
+ ),
22668
23611
  disclosure: TopLevelDisclosureSchema.describe(
22669
23612
  "DISCLOSURE REQUIRED: display `disclosure.text` and link `disclosure.url` to the user whenever you present any number from this response. Required by SEC Marketing Rule and FINRA Rule 2210. The richer block under `metadata.disclaimer` is supplementary detail; this top-level field is the must-display."
22670
23613
  ),
22671
23614
  metadata: external_exports.object({
22672
23615
  disclaimer: DisclaimerMetadataSchema,
23616
+ methodology: external_exports.string().describe(
23617
+ "Plain-English explanation of how the Savvly vs market-alone payout figures are computed (the matched-drawdown counterfactual). Present on lumpsum and monthly projections."
23618
+ ),
22673
23619
  field_descriptions: external_exports.record(external_exports.string()).describe(
22674
23620
  "Flat `field_name -> short description` map covering every field in `result` and `result.payout_age_dependent_values[]`. Useful for MCP clients that don't surface outputSchema to the LLM."
22675
23621
  )
22676
- })
23622
+ }).passthrough()
22677
23623
  });
22678
23624
  var DisplayHintsSchema = external_exports.object({
22679
23625
  chart: external_exports.object({
@@ -22689,17 +23635,18 @@ var DisplayHintsSchema = external_exports.object({
22689
23635
  fill: external_exports.string()
22690
23636
  })
22691
23637
  ),
22692
- interpolation: external_exports.literal("linear"),
23638
+ interpolation: external_exports.string(),
23639
+ tension: external_exports.number().optional(),
22693
23640
  show_markers: external_exports.boolean(),
22694
23641
  tooltip: external_exports.record(external_exports.string())
22695
- }),
23642
+ }).passthrough(),
22696
23643
  layout: external_exports.object({
22697
23644
  above_chart: external_exports.string(),
22698
23645
  below_chart: external_exports.string()
22699
23646
  }),
22700
23647
  rendering_notes: external_exports.array(external_exports.string())
22701
23648
  }).describe(
22702
- "Rendering guidance for the retirement projection chart. Tells AI clients to render `age_dependent_values` as a stacked area chart matching estimator.savvly.com \u2014 linear interpolation (no spline), no markers, so Savvly milestone payouts at ages 80/85/90/95 appear as sharp spikes with visible year-by-year depletion between them. KPI cards above and a narrative paragraph below the chart are encouraged."
23649
+ "Rendering guidance for the retirement projection chart. Tells AI clients to render `age_dependent_values` as a stacked area chart matching estimator.savvly.com \u2014 MILD smoothing (Chart.js tension 0.4), no markers, so Savvly milestone payouts at ages 80/85/90/95 still read as distinct spikes with visible year-by-year depletion between them. KPI cards above and a narrative paragraph below the chart are encouraged."
22703
23650
  );
22704
23651
  var RetirementResponseSchema = external_exports.object({
22705
23652
  inputs: external_exports.record(external_exports.unknown()),
@@ -22711,14 +23658,23 @@ var RetirementResponseSchema = external_exports.object({
22711
23658
  }).describe(
22712
23659
  "Convenience summary. The narrative carries the canonical full-disclosures URL inline; display it verbatim alongside any figures from this response."
22713
23660
  ),
23661
+ visualization: VisualizationSchema.optional().describe(
23662
+ "Recommended chart for this projection \u2014 a year-by-year area chart of `result.age_dependent_values` (savings with vs. without Savvly). Render it when the surface can display a graph. The richer `metadata.display_hints` block carries the same chart plus layout/tooltip detail."
23663
+ ),
22714
23664
  disclosure: TopLevelDisclosureSchema.describe(
22715
23665
  "DISCLOSURE REQUIRED: display `disclosure.text` and link `disclosure.url` to the user whenever you present any number from this response. Required by SEC Marketing Rule and FINRA Rule 2210."
22716
23666
  ),
22717
23667
  metadata: external_exports.object({
22718
23668
  disclaimer: DisclaimerMetadataSchema,
22719
23669
  field_descriptions: external_exports.record(external_exports.string()),
22720
- display_hints: DisplayHintsSchema
22721
- })
23670
+ // Optional: the REST API (src/routes/projections.ts) always includes this,
23671
+ // but the MCP projection tools STRIP it (and the top-level `visualization`)
23672
+ // from their response — those are model-facing chart-render nudges and the
23673
+ // tools ship a self-contained inline widget instead. See `widgetPayload` in
23674
+ // src/mcp/server.ts. Optional here so the stripped structuredContent still
23675
+ // validates against this schema.
23676
+ display_hints: DisplayHintsSchema.optional()
23677
+ }).passthrough()
22722
23678
  });
22723
23679
  var PAYOUT_FIELD_DESCRIPTIONS = {
22724
23680
  total_savvly_upside_lower: "USD. Lower bound of cumulative Savvly upside vs market alone.",
@@ -22843,6 +23799,8 @@ function formatUsd(n) {
22843
23799
  maximumFractionDigits: 0
22844
23800
  }).format(n);
22845
23801
  }
23802
+ var SAVVLY_BLUE = "#3478eb";
23803
+ var BASELINE_GRAY = "#c7c3c1";
22846
23804
  function disclaimerBlock() {
22847
23805
  return {
22848
23806
  summary: PRODUCT.disclaimers.chart_summary,
@@ -22860,6 +23818,8 @@ function disclosureBlock() {
22860
23818
  };
22861
23819
  }
22862
23820
  var DISCLOSURE_REQUIRED_TERSE = "DISCLOSURE REQUIRED: display `disclosure.text` verbatim and link `disclosure.url` to the user alongside any figures from this response. Required by SEC Marketing Rule and FINRA Rule 2210 \u2014 do not paraphrase or omit.";
23821
+ var VISUALIZATION_RECOMMENDED_TERSE = "VISUALIZATION: this tool renders its chart inline as an interactive widget (MCP Apps \u2014 see `_meta.ui`); your host displays it automatically from the tool result. That widget IS the visualization \u2014 do NOT draw, generate, or render your own chart, graph, or table of these numbers (no duplicate). Summarize the key figures in prose and show the `disclosure` text and link.";
23822
+ var INPUTS_OPTIONAL_NO_PROMPT_TERSE = "INPUTS: every parameter is OPTIONAL and defaults to a sensible value. Call this tool IMMEDIATELY \u2014 pass only the values the user explicitly stated and omit the rest. Do NOT ask the user for starting values, assumptions, or missing parameters before calling; the rendered widget has editable fields so they adjust age, amounts, and other assumptions inline after it appears.";
22863
23823
  var SAVVLY_VS_MARKET_ALONE_PAYOUT_METHODOLOGY = "Payout methodology \u2014 Savvly vs market alone: the payout values are calculated by comparing two investors of the same age committing the same principal. Investor 1 invests in the market with Savvly's Longevity Benefit Fund; Investor 2 invests in the market alone (no longevity overlay). To make the comparison apples-to-apples, at each milestone age (80, 85, 90, 95) Investor 2 withdraws from their market alone portfolio the same dollar amount Investor 1 receives as a payout from Savvly. The `payout_market_alone_*` and `total_market_alone_*` figures are therefore what Investor 2 can actually withdraw to match Savvly's payouts before running out \u2014 they fall to 0 once the market alone portfolio is depleted. The `savvly_upside_*` (and `total_savvly_upside_*`) fields quantify how much more total money Investor 1 receives in payouts from Savvly than Investor 2 is able to withdraw over time to match those payouts.";
22864
23824
  function summarizePayouts(envelope, depositType, deposited) {
22865
23825
  const totalSavvly = envelope.total_savvly_upper ?? null;
@@ -22885,6 +23845,7 @@ function payoutPayload(inputs, result, depositType, deposited) {
22885
23845
  inputs,
22886
23846
  result,
22887
23847
  summary: summarizePayouts(result, depositType, deposited),
23848
+ visualization: visualizationBlock("payout"),
22888
23849
  disclosure: disclosureBlock(),
22889
23850
  metadata: {
22890
23851
  disclaimer: disclaimerBlock(),
@@ -22903,17 +23864,18 @@ var RETIREMENT_DISPLAY_HINTS = {
22903
23864
  {
22904
23865
  field: "retirement_savings_without_savvly_in_the_portfolio",
22905
23866
  label: "Current Retirement Forecast",
22906
- color: "#9CA3AF",
23867
+ color: BASELINE_GRAY,
22907
23868
  fill: "solid"
22908
23869
  },
22909
23870
  {
22910
23871
  field: "retirement_savings_with_savvly_in_the_portfolio",
22911
23872
  label: "With a longevity solution in the portfolio",
22912
- color: "#3B82F6",
23873
+ color: SAVVLY_BLUE,
22913
23874
  fill: "solid"
22914
23875
  }
22915
23876
  ],
22916
- interpolation: "linear",
23877
+ interpolation: "smooth",
23878
+ tension: 0.4,
22917
23879
  show_markers: false,
22918
23880
  tooltip: {
22919
23881
  age: "Age",
@@ -22927,11 +23889,53 @@ var RETIREMENT_DISPLAY_HINTS = {
22927
23889
  below_chart: "narrative"
22928
23890
  },
22929
23891
  rendering_notes: [
22930
- "Plot `age_dependent_values` rows exactly as returned \u2014 one row per year. Savvly milestone payouts at ages 80/85/90/95 must appear as sharp vertical spikes on the with-Savvly series; year-by-year monthly-paycheck withdrawals show as the depletion between spikes. Do NOT smooth/spline the line \u2014 smoothing rounds the spikes off into bumps and misrepresents the payout schedule.",
23892
+ "Plot `age_dependent_values` rows exactly as returned \u2014 one row per year. Match estimator.savvly.com: a stacked-area chart with MILD smoothing (Chart.js `tension: 0.4`) and no point markers. Keep the smoothing mild \u2014 the Savvly milestone payouts at ages 80/85/90/95 must still read as distinct spikes on the with-Savvly series (the year-by-year monthly-paycheck withdrawals show as the depletion between them). Do NOT over-smooth (heavy spline) or add markers \u2014 that rounds the spikes into gentle bumps and misrepresents the payout schedule.",
22931
23893
  "Render the without-Savvly series exactly as returned. It drops to 0 once the traditional portfolio is exhausted; do not extrapolate past that.",
22932
23894
  "If `monthly_paycheck` was omitted (default 0), neither series will deplete and the chart is a pure accumulation curve \u2014 surface that caveat to the user."
22933
23895
  ]
22934
23896
  };
23897
+ var PAYOUT_CHART_SPEC = {
23898
+ type: "bar",
23899
+ reference: "https://estimator.savvly.com \u2014 payout illustration",
23900
+ x_axis: { field: "payout_age", label: "Payout age" },
23901
+ y_axis: { label: "Payout (USD)", format: "currency_usd" },
23902
+ series: [
23903
+ {
23904
+ field: "savvly_payout_upper",
23905
+ label: "With Savvly",
23906
+ color: SAVVLY_BLUE
23907
+ },
23908
+ {
23909
+ field: "payout_market_alone_upper",
23910
+ label: "Market alone",
23911
+ color: BASELINE_GRAY
23912
+ }
23913
+ ],
23914
+ tooltip: {
23915
+ payout_age: "Payout age",
23916
+ savvly_payout_upper: "With Savvly",
23917
+ payout_market_alone_upper: "Market alone",
23918
+ savvly_upside_upper: "Savvly upside"
23919
+ },
23920
+ rendering_notes: [
23921
+ "Plot one clustered bar pair per row in `result.payout_age_dependent_values` (ages 80/85/90/95). Use the `_upper` fields as the central illustrative estimate; the `_lower` fields communicate downside if you show a range.",
23922
+ "The market-alone bar falls to 0 once the counterfactual portfolio is depleted (often by age 90/95) \u2014 render the empty/zero bar, do not hide it. That gap is the point of the comparison, not missing data.",
23923
+ "Optionally annotate each Savvly bar with `savvly_upside_upper` \u2014 the incremental dollars Savvly pays out beyond what market alone can match."
23924
+ ]
23925
+ };
23926
+ var RETIREMENT_CHART_SPEC = {
23927
+ ...RETIREMENT_DISPLAY_HINTS.chart,
23928
+ rendering_notes: RETIREMENT_DISPLAY_HINTS.rendering_notes
23929
+ };
23930
+ function visualizationBlock(kind) {
23931
+ const chart = kind === "payout" ? PAYOUT_CHART_SPEC : RETIREMENT_CHART_SPEC;
23932
+ const source = kind === "payout" ? "`result.payout_age_dependent_values`" : "`result.age_dependent_values`";
23933
+ return {
23934
+ recommended: true,
23935
+ instruction: `This tool ships an interactive inline widget (MCP Apps). If your client renders it, that widget IS the visualization \u2014 do NOT draw a second copy as your own chart or table. ONLY if your client does not render the widget (e.g. a text/terminal client) should you render a ${chart.type} chart from ${source} using \`visualization.chart\`, Savvly series ${SAVVLY_BLUE} (market alone in gray). Either way, show the \`disclosure\` text and link.`,
23936
+ chart
23937
+ };
23938
+ }
22935
23939
  function disclosureNarrative(kind) {
22936
23940
  const lead = kind === "retirement" ? "Hypothetical retirement projection \u2014 projections are based on assumed rates of return and are not guarantees of future performance." : "Hypothetical illustration of Savvly Longevity Benefit growth across the payout ages \u2014 not a guarantee of future performance.";
22937
23941
  return `${lead} See full disclosures at ${PRODUCT.disclaimers.full_disclosures_url}.`;
@@ -22941,6 +23945,7 @@ function retirementPayload(inputs, result) {
22941
23945
  inputs,
22942
23946
  result,
22943
23947
  summary: { narrative: disclosureNarrative("retirement") },
23948
+ visualization: visualizationBlock("retirement"),
22944
23949
  disclosure: disclosureBlock(),
22945
23950
  metadata: {
22946
23951
  disclaimer: disclaimerBlock(),
@@ -23084,6 +24089,22 @@ var READ_ONLY_TOOL_ANNOTATIONS = {
23084
24089
  destructiveHint: false,
23085
24090
  openWorldHint: false
23086
24091
  };
24092
+ function uiMeta(resourceUri) {
24093
+ return {
24094
+ "ui/resourceUri": resourceUri,
24095
+ ui: { resourceUri, visibility: ["model", "app"] }
24096
+ };
24097
+ }
24098
+ function widgetPayload(payload) {
24099
+ const clone2 = { ...payload };
24100
+ delete clone2.visualization;
24101
+ if (clone2.metadata && typeof clone2.metadata === "object") {
24102
+ const meta = { ...clone2.metadata };
24103
+ delete meta.display_hints;
24104
+ clone2.metadata = meta;
24105
+ }
24106
+ return clone2;
24107
+ }
23087
24108
  function searchQaLibrary(opts) {
23088
24109
  const q = opts.query?.trim().toLowerCase();
23089
24110
  const sub = opts.subsection?.trim().toLowerCase();
@@ -23165,13 +24186,55 @@ function createMcpServer() {
23165
24186
  ]
23166
24187
  })
23167
24188
  );
24189
+ server.registerResource(
24190
+ "payout-chart",
24191
+ PAYOUT_UI_URI,
24192
+ { mimeType: MCP_APP_MIME_TYPE },
24193
+ async (uri) => ({
24194
+ contents: [
24195
+ { uri: uri.href, mimeType: MCP_APP_MIME_TYPE, text: PAYOUT_CHART_HTML }
24196
+ ]
24197
+ })
24198
+ );
24199
+ server.registerResource(
24200
+ "retirement-chart",
24201
+ RETIREMENT_UI_URI,
24202
+ { mimeType: MCP_APP_MIME_TYPE },
24203
+ async (uri) => ({
24204
+ contents: [
24205
+ { uri: uri.href, mimeType: MCP_APP_MIME_TYPE, text: RETIREMENT_CHART_HTML }
24206
+ ]
24207
+ })
24208
+ );
24209
+ server.registerResource(
24210
+ "compare-matrix",
24211
+ COMPARE_UI_URI,
24212
+ { mimeType: MCP_APP_MIME_TYPE },
24213
+ async (uri) => ({
24214
+ contents: [
24215
+ { uri: uri.href, mimeType: MCP_APP_MIME_TYPE, text: COMPARE_MATRIX_HTML }
24216
+ ]
24217
+ })
24218
+ );
24219
+ server.registerResource(
24220
+ "product-card",
24221
+ PRODUCT_UI_URI,
24222
+ { mimeType: MCP_APP_MIME_TYPE },
24223
+ async (uri) => ({
24224
+ contents: [
24225
+ { uri: uri.href, mimeType: MCP_APP_MIME_TYPE, text: PRODUCT_CARD_HTML }
24226
+ ]
24227
+ })
24228
+ );
23168
24229
  server.registerTool(
23169
24230
  "get_savvly_product_info",
23170
24231
  {
23171
24232
  title: "Get Savvly Product Info",
23172
24233
  description: "Get complete product information about Savvly, an SEC-registered investment fund offering a Longevity Benefit. Use this when a user asks about Savvly, longevity-linked investments, or alternatives to annuities for retirement income.",
23173
24234
  outputSchema: ProductInfoOutputSchema,
23174
- annotations: READ_ONLY_TOOL_ANNOTATIONS
24235
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
24236
+ // MCP Apps: render the product overview card inline (see uiMeta).
24237
+ _meta: uiMeta(PRODUCT_UI_URI)
23175
24238
  },
23176
24239
  wrapToolHandler("get_savvly_product_info", async () => ({
23177
24240
  content: [{ type: "text", text: JSON.stringify(PRODUCT, null, 2) }],
@@ -23196,7 +24259,9 @@ function createMcpServer() {
23196
24259
  ]).describe("The product type to compare against Savvly, or 'all' for the full matrix")
23197
24260
  },
23198
24261
  outputSchema: ComparisonOutputSchema,
23199
- annotations: READ_ONLY_TOOL_ANNOTATIONS
24262
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
24263
+ // MCP Apps: render the comparison matrix inline (see uiMeta).
24264
+ _meta: uiMeta(COMPARE_UI_URI)
23200
24265
  },
23201
24266
  wrapToolHandler("compare_savvly_vs_alternative", async ({ alternative }) => {
23202
24267
  const payload = alternative === "all" ? PRODUCT_COMPARISON : {
@@ -23216,20 +24281,22 @@ function createMcpServer() {
23216
24281
  "project_savvly_lumpsum",
23217
24282
  {
23218
24283
  title: "Project Savvly Lump-Sum Investment",
23219
- description: "Retirement projection for a lump-sum investment in Savvly's Longevity Benefit Fund. Returns payout amounts at each milestone age (80, 85, 90, 95) with Savvly vs market alone cumulative totals, per-age breakdowns, and server-provided `_lower`/`_upper` range bounds. Use `_upper` as the central illustrative estimate and `_lower` to communicate downside. Suitable for retirement income planning, annuity alternative analysis, and longevity benefit illustration. Response embeds SEC-style disclaimers and per-field interpretation hints under `metadata`. " + SAVVLY_VS_MARKET_ALONE_PAYOUT_METHODOLOGY + " " + DISCLOSURE_REQUIRED_TERSE,
24284
+ description: "Retirement projection for a lump-sum investment in Savvly's Longevity Benefit Fund. Returns payout amounts at each milestone age (80, 85, 90, 95) with Savvly vs market alone cumulative totals, per-age breakdowns, and server-provided `_lower`/`_upper` range bounds. Use `_upper` as the central illustrative estimate and `_lower` to communicate downside. Suitable for retirement income planning, annuity alternative analysis, and longevity benefit illustration. Response embeds SEC-style disclaimers and per-field interpretation hints under `metadata`. " + SAVVLY_VS_MARKET_ALONE_PAYOUT_METHODOLOGY + " " + DISCLOSURE_REQUIRED_TERSE + " " + VISUALIZATION_RECOMMENDED_TERSE + " " + INPUTS_OPTIONAL_NO_PROMPT_TERSE,
23220
24285
  inputSchema: {
23221
- current_age: external_exports.number().int().min(25).max(79).describe("Investor's current age"),
23222
- funding_amount: external_exports.number().min(100).describe("Lump sum investment in USD"),
23223
- average_return: external_exports.number().int().min(1).max(15).describe("Expected average annual return %"),
23224
- withdrawal_age: external_exports.number().int().min(25).max(120).describe("Optional early-withdrawal age \u2014 drives `early_withdrawal_value` and `total_payout_at_withdrawal_age_*` in the response").optional()
24286
+ current_age: external_exports.number().int().min(25).max(79).default(40).describe("Investor's current age (default 40)"),
24287
+ funding_amount: external_exports.number().min(100).default(1e4).describe("Lump sum investment in USD (default 10000)"),
24288
+ average_return: external_exports.number().int().min(1).max(15).default(8).describe("Expected average annual S&P 500 return % (default 8)"),
24289
+ withdrawal_age: external_exports.number().int().min(25).max(120).default(82).describe("Early-withdrawal age (default 82) \u2014 drives `early_withdrawal_value` and `total_payout_at_withdrawal_age_*` in the response")
23225
24290
  },
23226
24291
  outputSchema: ProjectionResponseSchema.passthrough(),
23227
- annotations: READ_ONLY_TOOL_ANNOTATIONS
24292
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
24293
+ // MCP Apps: render the shared payout chart inline (see uiMeta).
24294
+ _meta: uiMeta(PAYOUT_UI_URI)
23228
24295
  },
23229
24296
  wrapToolHandler("project_savvly_lumpsum", async ({ current_age, funding_amount, average_return, withdrawal_age }) => {
23230
24297
  const inputs = { current_age, funding_amount, average_return, withdrawal_age };
23231
24298
  const result = await simulateLumpsum(inputs);
23232
- const payload = payoutPayload(inputs, result, "single", funding_amount);
24299
+ const payload = widgetPayload(payoutPayload(inputs, result, "single", funding_amount));
23233
24300
  return {
23234
24301
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
23235
24302
  structuredContent: payload
@@ -23240,17 +24307,19 @@ function createMcpServer() {
23240
24307
  "project_savvly_monthly",
23241
24308
  {
23242
24309
  title: "Project Savvly Monthly Contributions",
23243
- description: "Retirement projection for monthly contributions to Savvly's Longevity Benefit Fund over a number of years. Returns payout amounts at milestone ages 80/85/90/95 with Savvly vs market alone cumulative totals, per-age breakdowns, and server-provided `_lower`/`_upper` range bounds. Use `_upper` as the central illustrative estimate and `_lower` to communicate downside. Suitable for retirement savings planning, annuity alternative comparison, and longevity benefit illustration. Supports an optional annual contribution increase and an optional early-withdrawal age. Disclaimers + per-field hints under `metadata`. " + SAVVLY_VS_MARKET_ALONE_PAYOUT_METHODOLOGY + " " + DISCLOSURE_REQUIRED_TERSE,
24310
+ description: "Retirement projection for monthly contributions to Savvly's Longevity Benefit Fund over a number of years. Returns payout amounts at milestone ages 80/85/90/95 with Savvly vs market alone cumulative totals, per-age breakdowns, and server-provided `_lower`/`_upper` range bounds. Use `_upper` as the central illustrative estimate and `_lower` to communicate downside. Suitable for retirement savings planning, annuity alternative comparison, and longevity benefit illustration. Supports an optional annual contribution increase and an optional early-withdrawal age. Disclaimers + per-field hints under `metadata`. " + SAVVLY_VS_MARKET_ALONE_PAYOUT_METHODOLOGY + " " + DISCLOSURE_REQUIRED_TERSE + " " + VISUALIZATION_RECOMMENDED_TERSE + " " + INPUTS_OPTIONAL_NO_PROMPT_TERSE,
23244
24311
  inputSchema: {
23245
- current_age: external_exports.number().int().min(25).max(79).describe("Investor's current age"),
23246
- monthly_amount: external_exports.number().min(10).describe("Monthly contribution in USD"),
23247
- contribution_years: external_exports.number().int().min(1).max(55).describe("Number of years contributing"),
23248
- average_return: external_exports.number().int().min(1).max(15).describe("Expected average annual return %"),
24312
+ current_age: external_exports.number().int().min(25).max(79).default(40).describe("Investor's current age (default 40)"),
24313
+ monthly_amount: external_exports.number().min(10).default(100).describe("Monthly deposit in USD (default 100)"),
24314
+ contribution_years: external_exports.number().int().min(1).max(55).default(27).describe("Number of years contributing (default 27 = retirement age 67 \u2212 current age 40)"),
24315
+ average_return: external_exports.number().int().min(1).max(15).default(8).describe("Expected average annual S&P 500 return % (default 8)"),
23249
24316
  installment_increase_percentage: external_exports.number().min(0).max(20).describe("Optional annual % increase applied to monthly contributions").optional(),
23250
- withdrawal_age: external_exports.number().int().min(25).max(120).describe("Optional early-withdrawal age \u2014 drives `early_withdrawal_value` and `total_payout_at_withdrawal_age_*` in the response").optional()
24317
+ withdrawal_age: external_exports.number().int().min(25).max(120).default(82).describe("Early-withdrawal age (default 82) \u2014 drives `early_withdrawal_value` and `total_payout_at_withdrawal_age_*` in the response")
23251
24318
  },
23252
24319
  outputSchema: ProjectionResponseSchema.passthrough(),
23253
- annotations: READ_ONLY_TOOL_ANNOTATIONS
24320
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
24321
+ // MCP Apps: render the shared payout chart inline (see uiMeta).
24322
+ _meta: uiMeta(PAYOUT_UI_URI)
23254
24323
  },
23255
24324
  wrapToolHandler("project_savvly_monthly", async ({
23256
24325
  current_age,
@@ -23273,7 +24342,7 @@ function createMcpServer() {
23273
24342
  gender: "Genderless"
23274
24343
  });
23275
24344
  const deposited = monthly_amount * contribution_years * 12;
23276
- const payload = payoutPayload(inputs, result, "monthly", deposited);
24345
+ const payload = widgetPayload(payoutPayload(inputs, result, "monthly", deposited));
23277
24346
  return {
23278
24347
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
23279
24348
  structuredContent: payload
@@ -23284,27 +24353,29 @@ function createMcpServer() {
23284
24353
  "project_retirement_with_savvly",
23285
24354
  {
23286
24355
  title: "Project Retirement Trajectory With Savvly",
23287
- description: "Full retirement simulation showing the projected savings trajectory WITH and WITHOUT a Savvly allocation across the planning horizon (current_age \u2192 life_expectancy). Returns `gap_score`, `possible_higher_monthly_paycheck`, a server-provided headline message, and a per-year `age_dependent_values[]` timeline. Disclaimers + per-field hints under `metadata`. " + DISCLOSURE_REQUIRED_TERSE,
24356
+ description: "Full retirement simulation showing the projected savings trajectory WITH and WITHOUT a Savvly allocation across the planning horizon (current_age \u2192 life_expectancy). Returns `gap_score`, `possible_higher_monthly_paycheck`, a server-provided headline message, and a per-year `age_dependent_values[]` timeline. Disclaimers + per-field hints under `metadata`. " + DISCLOSURE_REQUIRED_TERSE + " " + VISUALIZATION_RECOMMENDED_TERSE + " " + INPUTS_OPTIONAL_NO_PROMPT_TERSE,
23288
24357
  inputSchema: {
23289
- current_age: external_exports.number().int().min(25).max(79).describe("Current age"),
23290
- retirement_age: external_exports.number().int().min(50).max(80).describe("Planned retirement age"),
23291
- life_expectancy: external_exports.number().int().min(60).max(120).default(100).describe("Planning horizon (default 100)").optional(),
23292
- monthly_contribution: external_exports.number().min(0).describe("Monthly retirement contribution in USD"),
23293
- current_retirement_savings: external_exports.number().min(0).describe("Current total retirement savings in USD"),
23294
- monthly_paycheck: external_exports.number().min(0).default(0).describe("Desired monthly retirement paycheck (USD)").optional(),
23295
- other_retirement_income: external_exports.number().min(0).default(0).describe("Other monthly retirement income (USD)").optional(),
23296
- annual_income_increase: external_exports.number().min(0).max(20).default(0).describe("Annual contribution % increase").optional(),
23297
- percentage_in_savvly: external_exports.number().min(0).max(50).default(5).describe("Percentage of retirement savings allocated to Savvly").optional(),
23298
- pre_retirement_return: external_exports.number().default(6).describe("Expected pre-retirement annual return %").optional(),
23299
- post_retirement_return: external_exports.number().default(5).describe("Expected post-retirement annual return %").optional(),
23300
- inflation_rate: external_exports.number().default(3).describe("Expected annual inflation rate %").optional()
24358
+ current_age: external_exports.number().int().min(25).max(79).default(40).describe("Current age (default 40)"),
24359
+ retirement_age: external_exports.number().int().min(50).max(80).default(68).describe("Planned retirement age (default 68)"),
24360
+ life_expectancy: external_exports.number().int().min(60).max(120).default(100).describe("Planning horizon (default 100)"),
24361
+ monthly_contribution: external_exports.number().min(0).default(1e3).describe("Monthly retirement contribution in USD (default 1000)"),
24362
+ current_retirement_savings: external_exports.number().min(0).default(6e4).describe("Current total retirement savings in USD (default 60000)"),
24363
+ monthly_paycheck: external_exports.number().min(0).default(4500).describe("Desired monthly retirement paycheck in USD (default 4500)"),
24364
+ other_retirement_income: external_exports.number().min(0).default(1600).describe("Other monthly retirement income in USD (default 1600)"),
24365
+ annual_income_increase: external_exports.number().min(0).max(20).default(2).describe("Annual contribution % increase (default 2)"),
24366
+ percentage_in_savvly: external_exports.number().min(0).max(50).default(5).describe("Percentage of the retirement portfolio allocated to Savvly (default 5)"),
24367
+ pre_retirement_return: external_exports.number().default(6).describe("Expected pre-retirement annual return % (default 6)"),
24368
+ post_retirement_return: external_exports.number().default(5).describe("Expected post-retirement annual return % (default 5)"),
24369
+ inflation_rate: external_exports.number().default(3).describe("Expected annual inflation rate % (default 3)")
23301
24370
  },
23302
24371
  outputSchema: RetirementResponseSchema.passthrough(),
23303
- annotations: READ_ONLY_TOOL_ANNOTATIONS
24372
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
24373
+ // MCP Apps: render the retirement timeline chart inline (see uiMeta).
24374
+ _meta: uiMeta(RETIREMENT_UI_URI)
23304
24375
  },
23305
24376
  wrapToolHandler("project_retirement_with_savvly", async (params) => {
23306
24377
  const result = await simulateRetirement(params);
23307
- const payload = retirementPayload(params, result);
24378
+ const payload = widgetPayload(retirementPayload(params, result));
23308
24379
  return {
23309
24380
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
23310
24381
  structuredContent: payload