Skip to content

Commit e7266b9

Browse files
authored
Merge pull request #3 from wangcan26/feature/mcp_app
feat: support mcp app
2 parents 5a98c6b + 97d9ccd commit e7266b9

55 files changed

Lines changed: 5310 additions & 20 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,19 @@ Always push the branch before creating a PR.
306306
| `DSCODE_CONFIG_HOME` | 自定义配置目录(默认 `~/.dscode`|
307307
| `DSCODE_DATA_HOME` | 自定义数据目录(默认 `~/.dscode`|
308308

309+
## Examples
310+
311+
`examples/` 提供可直接运行的示例项目,用来演示 dscode 的 MCP 集成方式和交互式 UI 能力。
312+
313+
### MCP App
314+
315+
| 示例 | 说明 | 快速开始 |
316+
| --- | --- | --- |
317+
| `examples/scenario-modeler` | 一个 SaaS 场景建模 MCP Server。演示 tool 返回 `structuredContent` 后,dscode 如何渲染 MCP App;没有 server HTML 时走 MDX,有 HTML resource 时优先使用 server 自带页面。 | `cd examples/scenario-modeler && npm install && npm start` |
318+
319+
更多使用说明见:
320+
- `examples/scenario-modeler/README.md`
321+
309322
## Project structure
310323

311324
```text
@@ -316,7 +329,7 @@ src/
316329
├── memory/ # 跨 session 记忆
317330
├── drivers/ # 驱动注册 + 内置驱动 (fs, shell, search)
318331
├── skills/ # Skill 管理器 + SKILL.md 加载器
319-
├── mcp/ # MCP 客户端(stdio/SSE)+ 管理器
332+
├── mcp/ # MCP 客户端(stdio/SSE)+ 管理器 + MCP App host/runtime
320333
├── permissions/ # 权限拦截
321334
└── ui/ # REPL、流式渲染、slash commands
322335
```
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"mcpServers": {
3+
"scenario-modeler": {
4+
"command": "npx",
5+
"args": ["tsx", "server.ts", "--stdio"],
6+
"description": "MCP App - SaaS Scenario Modeler"
7+
}
8+
}
9+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
dist/
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Scenario Modeler — MCP App Example for dscode
2+
3+
A SaaS financial scenario modeler MCP App. Interactive 12-month projections with templates and parameter sliders.
4+
5+
## Quick Start
6+
7+
```bash
8+
cd examples/scenario-modeler
9+
npm install
10+
npm --prefix ../.. run build
11+
npm start
12+
```
13+
14+
`npm start` launches the current repo build of dscode from this example directory. dscode then starts the `scenario-modeler` MCP server through the existing stdio MCP config, so you only need one terminal.
15+
16+
Then ask the agent: "Show me the current SaaS scenario projections."
17+
18+
If the agent replies with plain text only, ask it to use the `get-scenario-data` MCP tool explicitly.
19+
20+
Agent calls `get-scenario-data` → dscode renders the MCP App → TUI highlights the localhost link → open it in your browser.
21+
22+
## Alternate startup modes
23+
24+
```bash
25+
npm run start:server # start only the HTTP MCP server on localhost:3100
26+
npm run start:stdio # start only the stdio MCP server
27+
```
28+
29+
## How it works
30+
31+
- **server.ts** — Standard MCP server using `@modelcontextprotocol/sdk`. Registers:
32+
- `get-scenario-data` tool with `_meta.ui.resourceUri = "ui://scenario-modeler/mcp-app"`
33+
- Returns `structuredContent` with templates, projections, and summary data
34+
- Includes `_ui.mdx` to demonstrate a custom MDX layout override
35+
- **No HTML required** — dscode renders the dashboard from data + MDX
36+
- **Auto-generated UI** — dscode inspects `structuredContent` and renders:
37+
- Chart from projection arrays (line chart with MRR/netProfit curves)
38+
- Metrics cards from summary key-value pairs
39+
- Table from template/projection data
40+
- **No external dependencies** — UI is rendered by dscode's built-in MDX Runtime
41+
42+
## Features
43+
44+
- 12-month line chart (MRR, Gross Profit, Net Profit) — auto-generated from data
45+
- Metric cards showing ending MRR, ARR, total revenue, profit, growth %, break-even
46+
- 5 pre-built templates (Bootstrapped, VC Rocketship, Cash Cow, Turnaround, Efficient Growth)
47+
- Custom projection computation via tool arguments
48+
- Light/dark theme support (via CSS custom properties)
49+
50+
## Transport
51+
52+
```bash
53+
npm start # HTTP (default, port 3100)
54+
npm run start:stdio # stdio for direct MCP client connection
55+
```
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Scenario Modeler</title>
4+
<style>
5+
:root{--bg:#fff;--bg2:#f9fafb;--bg3:#f3f4f6;--text:#111827;--text2:#6b7280;--text3:#9ca3af;--border:#e5e7eb;--blue:#3b82f6;--green:#10b981;--amber:#f59e0b;--red:#ef4444;--slider-track:#d1d5db;--slider-thumb:#3b82f6}
6+
@media(prefers-color-scheme:dark){:root{--bg:#111827;--bg2:#1f2937;--bg3:#374151;--text:#f9fafb;--text2:#9ca3af;--text3:#6b7280;--border:#374151;--slider-track:#4b5563;--slider-thumb:#60a5fa}}
7+
*{margin:0;padding:0;box-sizing:border-box}
8+
html,body{width:100%;height:100%;font-family:system-ui,-apple-system,sans-serif;font-size:14px;background:var(--bg);color:var(--text);overflow:hidden}
9+
.main{width:100%;height:100%;display:flex;flex-direction:column}
10+
.header{height:40px;display:flex;align-items:center;justify-content:space-between;padding:0 10px;border-bottom:1px solid var(--border);flex-shrink:0}
11+
.header-title{font-size:14px;font-weight:600;white-space:nowrap}
12+
.header-ctrls{display:flex;align-items:center;gap:6px}
13+
.template-select,.btn{font-size:12px;padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg2);color:var(--text);cursor:pointer}
14+
.btn:hover{background:var(--bg3)}
15+
.params{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
16+
.section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text2);margin:0 0 4px 0}
17+
.slider-row{display:flex;align-items:center;height:22px;gap:8px}
18+
.slider-label{width:90px;font-size:12px;color:var(--text);flex-shrink:0}
19+
.slider-input{flex:1;height:4px;-webkit-appearance:none;appearance:none;background:var(--slider-track);border-radius:2px;cursor:pointer}
20+
.slider-input::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--slider-thumb);cursor:pointer}
21+
.slider-value{width:45px;font-size:12px;font-weight:500;color:var(--text);text-align:right;flex-shrink:0}
22+
.chart-section{padding:8px 10px;border-bottom:1px solid var(--border);flex:2;display:flex;flex-direction:column;min-height:0}
23+
.chart-container{flex:1;position:relative;min-height:0}
24+
.chart-container canvas{width:100%!important;height:100%!important}
25+
.chart-legend{display:flex;gap:16px;justify-content:center;font-size:11px;color:var(--text2);padding-top:4px}
26+
.legend-item{display:flex;align-items:center;gap:4px}
27+
.legend-color{display:inline-block;width:10px;height:2px;border-radius:1px}
28+
.metrics{padding:6px 10px;flex:1;display:flex;flex-direction:column;min-height:0}
29+
.metrics-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:6px}
30+
.metric-card{background:var(--bg2);border-radius:6px;padding:4px 8px;text-align:center}
31+
.metric-value{font-size:16px;font-weight:600;color:var(--text)}
32+
.metric-label{font-size:10px;color:var(--text2);margin-top:2px}
33+
.green{color:var(--green)}.red{color:var(--red)}.amber{color:var(--amber)}
34+
.metrics-summary{padding-top:6px;border-top:1px solid var(--border);font-size:11px;color:var(--text2);display:flex;justify-content:space-between}
35+
.loading{display:flex;align-items:center;justify-content:center;height:100%;color:var(--text2);font-size:14px}
36+
</style></head>
37+
<body>
38+
<main class="main" id="app"><div class="loading" id="loading">Connecting to dscode...</div></main>
39+
<script>
40+
// ================================================================
41+
// Pure JS MCP App — no framework, no external dependencies
42+
// Canvas API for chart, DOM API for UI
43+
// ================================================================
44+
45+
// --- MCP JSON-RPC over postMessage ---
46+
var reqId = 1, pending = {}, hostCtx = null;
47+
var templates = [], defaultInputs = {startingMRR:50000,monthlyGrowthRate:5,monthlyChurnRate:3,grossMargin:80,fixedCosts:30000};
48+
49+
function send(method, params) {
50+
return new Promise(function(resolve, reject) {
51+
var id = reqId++;
52+
pending[id] = {resolve:resolve, reject:reject};
53+
window.parent.postMessage({jsonrpc:'2.0',id:id,method:method,params:params},'*');
54+
setTimeout(function(){ if(pending[id]){delete pending[id]; reject(new Error('timeout'));} },30000);
55+
});
56+
}
57+
58+
window.addEventListener('message', function(e) {
59+
var m = e.data;
60+
if (!m || typeof m !== 'object') return;
61+
if (m.id !== undefined && (m.result !== undefined || m.error !== undefined)) {
62+
var p = pending[m.id];
63+
if (p) { delete pending[m.id]; m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result); }
64+
}
65+
if (m.method) handleNotification(m.method, m.params);
66+
});
67+
68+
var notifHandlers = {};
69+
function onNotif(method, fn) {
70+
if (!notifHandlers[method]) notifHandlers[method] = [];
71+
notifHandlers[method].push(fn);
72+
}
73+
function handleNotification(method, params) {
74+
var hs = notifHandlers[method] || [];
75+
for (var i = 0; i < hs.length; i++) hs[i](params);
76+
}
77+
78+
// --- Business Logic ---
79+
function calcProjections(inp) {
80+
var r = (inp.monthlyGrowthRate - inp.monthlyChurnRate) / 100;
81+
var ps = [], cum = 0;
82+
for (var m = 1; m <= 12; m++) {
83+
var mrr = inp.startingMRR * Math.pow(1 + r, m);
84+
var gp = mrr * (inp.grossMargin / 100);
85+
var np = gp - inp.fixedCosts;
86+
cum += mrr;
87+
ps.push({month:m, mrr:mrr, grossProfit:gp, netProfit:np, cumulativeRevenue:cum});
88+
}
89+
return ps;
90+
}
91+
92+
function calcSummary(ps, inp) {
93+
var e = ps[11].mrr;
94+
var tr = 0, tp = 0;
95+
for (var i = 0; i < ps.length; i++) { tr += ps[i].mrr; tp += ps[i].netProfit; }
96+
var bp = null;
97+
for (var i = 0; i < ps.length; i++) { if (ps[i].netProfit >= 0) { bp = ps[i].month; break; } }
98+
return {endingMRR:e,arr:e*12,totalRevenue:Math.round(tr),totalProfit:Math.round(tp),
99+
mrrGrowthPct:Math.round(((e-inp.startingMRR)/inp.startingMRR)*1000)/10,
100+
avgMargin:Math.round((tp/tr)*1000)/10, breakEvenMonth:bp};
101+
}
102+
103+
function fmtCurrency(v) {
104+
var a = Math.abs(v), s = v < 0 ? '-' : '';
105+
if (a >= 1e6) return s + '$' + (a/1e6).toFixed(2) + 'M';
106+
if (a >= 1e3) return s + '$' + (a/1e3).toFixed(1) + 'K';
107+
return s + '$' + Math.round(a);
108+
}
109+
function fmtPct(v, sign) { return (sign && v > 0 ? '+' : '') + v.toFixed(1).replace(/\.0$/, '') + '%'; }
110+
111+
// --- Canvas Chart ---
112+
function drawChart(canvas, userProj, tmplProj, tmplName) {
113+
var ctx = canvas.getContext('2d');
114+
var W = canvas.width = canvas.offsetWidth;
115+
var H = canvas.height = canvas.offsetHeight;
116+
var pad = {top:20, right:20, bottom:30, left:50};
117+
var pw = W - pad.left - pad.right;
118+
var ph = H - pad.top - pad.bottom;
119+
120+
ctx.clearRect(0,0,W,H);
121+
122+
// Find max value
123+
var maxVal = 0;
124+
for (var i = 0; i < 12; i++) {
125+
maxVal = Math.max(maxVal, Math.abs(userProj[i].mrr), Math.abs(userProj[i].netProfit));
126+
if (tmplProj) maxVal = Math.max(maxVal, Math.abs(tmplProj[i].mrr), Math.abs(tmplProj[i].netProfit));
127+
}
128+
maxVal = Math.ceil(maxVal/5000)*5000;
129+
130+
// Grid
131+
var isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
132+
var gridColor = isDark ? '#374151' : '#e5e7eb';
133+
var textColor = isDark ? '#9ca3af' : '#6b7280';
134+
ctx.strokeStyle = gridColor;
135+
ctx.lineWidth = 0.5;
136+
var ySteps = 5;
137+
for (var i = 0; i <= ySteps; i++) {
138+
var y = pad.top + (ph * i / ySteps);
139+
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(W-pad.right, y); ctx.stroke();
140+
var val = maxVal - (maxVal * i / ySteps);
141+
ctx.fillStyle = textColor; ctx.font = '10px system-ui';
142+
ctx.textAlign = 'right';
143+
ctx.fillText(fmtCurrency(val), pad.left - 4, y + 4);
144+
}
145+
146+
// X labels
147+
ctx.textAlign = 'center';
148+
for (var i = 0; i < 12; i++) {
149+
var x = pad.left + (pw * i / 11);
150+
ctx.fillText('M' + (i+1), x, H - pad.bottom + 14);
151+
}
152+
153+
function toX(i) { return pad.left + (pw * i / 11); }
154+
function toY(v) { return pad.top + ph - (ph * v / maxVal); }
155+
156+
function drawLine(data, color, dashed) {
157+
ctx.beginPath();
158+
ctx.strokeStyle = color;
159+
ctx.lineWidth = dashed ? 1.5 : 2;
160+
if (dashed) ctx.setLineDash([5, 5]); else ctx.setLineDash([]);
161+
ctx.moveTo(toX(0), toY(data[0]));
162+
for (var i = 1; i < 12; i++) ctx.lineTo(toX(i), toY(data[i]));
163+
ctx.stroke();
164+
ctx.setLineDash([]);
165+
}
166+
167+
drawLine(userProj.map(function(p){return p.mrr}), '#3b82f6', false);
168+
drawLine(userProj.map(function(p){return p.grossProfit}), '#10b981', false);
169+
drawLine(userProj.map(function(p){return p.netProfit}), '#f59e0b', false);
170+
171+
if (tmplProj) {
172+
drawLine(tmplProj.map(function(p){return p.mrr}), '#3b82f6', true);
173+
drawLine(tmplProj.map(function(p){return p.grossProfit}), '#10b981', true);
174+
drawLine(tmplProj.map(function(p){return p.netProfit}), '#f59e0b', true);
175+
}
176+
}
177+
178+
// --- DOM Rendering ---
179+
var currentInputs = {startingMRR:50000,monthlyGrowthRate:5,monthlyChurnRate:3,grossMargin:80,fixedCosts:30000};
180+
var selectedTmplId = null;
181+
182+
function render() {
183+
var proj = calcProjections(currentInputs);
184+
var summary = calcSummary(proj, currentInputs);
185+
var tmpl = null;
186+
for (var i = 0; i < templates.length; i++) { if (templates[i].id === selectedTmplId) { tmpl = templates[i]; break; } }
187+
188+
var tmplOpts = templates.map(function(t){ return '<option value="' + t.id + '"' + (t.id === selectedTmplId ? ' selected' : '') + '>' + t.icon + ' ' + t.name + '</option>'; }).join('');
189+
190+
document.getElementById('app').innerHTML =
191+
'<header class="header"><h1 class="header-title">SaaS Scenario Modeler</h1><div class="header-ctrls">' +
192+
'<select class="template-select" id="tmpl-select"><option value="">Compare to...</option>' + tmplOpts + '</select>' +
193+
(tmpl ? '<button class="btn" id="load-btn">Load</button>' : '') +
194+
'<button class="btn" id="reset-btn">Reset</button></div></header>' +
195+
'<section class="params"><h2 class="section-title">Parameters</h2>' +
196+
sliderRow('Starting MRR', 'mrr', currentInputs.startingMRR, 10000, 500000, 5000, fmtCurrency) +
197+
sliderRow('Growth Rate', 'grow', currentInputs.monthlyGrowthRate, 0, 20, 0.5, function(v){return fmtPct(v)}) +
198+
sliderRow('Churn Rate', 'churn', currentInputs.monthlyChurnRate, 0, 15, 0.5, function(v){return fmtPct(v)}) +
199+
sliderRow('Gross Margin', 'margin', currentInputs.grossMargin, 50, 95, 5, function(v){return fmtPct(v)}) +
200+
sliderRow('Fixed Costs', 'costs', currentInputs.fixedCosts, 5000, 200000, 5000, fmtCurrency) +
201+
'</section>' +
202+
'<section class="chart-section"><h2 class="section-title">12-Month Projection</h2>' +
203+
'<div class="chart-container"><canvas id="chart"></canvas></div>' +
204+
'<div class="chart-legend"><span class="legend-item"><span class="legend-color" style="background:#3b82f6"></span> MRR</span>' +
205+
'<span class="legend-item"><span class="legend-color" style="background:#10b981"></span> Gross Profit</span>' +
206+
'<span class="legend-item"><span class="legend-color" style="background:#f59e0b"></span> Net Profit</span>' +
207+
(tmpl ? '<span class="legend-item" style="color:var(--text3)">(dashed = ' + tmpl.name + ')</span>' : '') +
208+
'</div></section>' +
209+
'<section class="metrics"><div class="metrics-grid">' +
210+
metricCard('End MRR', fmtCurrency(summary.endingMRR)) +
211+
metricCard('Total Revenue', fmtCurrency(summary.totalRevenue)) +
212+
metricCard('Total Profit', fmtCurrency(summary.totalProfit), summary.totalProfit >= 0 ? 'green' : 'red') +
213+
'</div>' +
214+
'<div class="metrics-summary"><span>Break-even: <b>' + (summary.breakEvenMonth ? 'Month ' + summary.breakEvenMonth : 'Not achieved') + '</b></span>' +
215+
'<span>MRR Growth: <b class="' + (summary.mrrGrowthPct >= 0 ? 'green' : 'red') + '">' + fmtPct(summary.mrrGrowthPct) + '</b></span></div>' +
216+
'</section>';
217+
218+
bindEvents();
219+
var c = document.getElementById('chart');
220+
if (c) drawChart(c, proj, tmpl ? tmpl.projections : null, tmpl ? tmpl.name : null);
221+
}
222+
223+
function sliderRow(label, name, value, min, max, step, format) {
224+
return '<div class="slider-row"><span class="slider-label">' + label + '</span>' +
225+
'<input type="range" class="slider-input" id="sl-' + name + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + value + '">' +
226+
'<span class="slider-value" id="sv-' + name + '">' + format(value) + '</span></div>';
227+
}
228+
229+
function metricCard(label, value, cls) {
230+
return '<div class="metric-card"><div class="metric-value' + (cls ? ' ' + cls : '') + '">' + value + '</div><div class="metric-label">' + label + '</div></div>';
231+
}
232+
233+
function bindEvents() {
234+
function bind(id, evt, fn) { var el = document.getElementById(id); if (el) el.addEventListener(evt, fn); }
235+
bind('sl-mrr', 'input', function(e){ currentInputs.startingMRR = Number(e.target.value); document.getElementById('sv-mrr').textContent = fmtCurrency(currentInputs.startingMRR); render(); });
236+
bind('sl-grow', 'input', function(e){ currentInputs.monthlyGrowthRate = Number(e.target.value); document.getElementById('sv-grow').textContent = fmtPct(currentInputs.monthlyGrowthRate); render(); });
237+
bind('sl-churn', 'input', function(e){ currentInputs.monthlyChurnRate = Number(e.target.value); document.getElementById('sv-churn').textContent = fmtPct(currentInputs.monthlyChurnRate); render(); });
238+
bind('sl-margin', 'input', function(e){ currentInputs.grossMargin = Number(e.target.value); document.getElementById('sv-margin').textContent = fmtPct(currentInputs.grossMargin); render(); });
239+
bind('sl-costs', 'input', function(e){ currentInputs.fixedCosts = Number(e.target.value); document.getElementById('sv-costs').textContent = fmtCurrency(currentInputs.fixedCosts); render(); });
240+
bind('tmpl-select', 'change', function(e){ selectedTmplId = e.target.value || null; render(); });
241+
bind('load-btn', 'click', function(){
242+
var tmpl = null;
243+
for (var i = 0; i < templates.length; i++) { if (templates[i].id === selectedTmplId) { tmpl = templates[i]; break; } }
244+
if (tmpl) { currentInputs = Object.assign({}, tmpl.parameters); selectedTmplId = null; render(); }
245+
});
246+
bind('reset-btn', 'click', function(){ currentInputs = Object.assign({}, defaultInputs); selectedTmplId = null; render(); });
247+
}
248+
249+
// --- MCP App Lifecycle ---
250+
async function init() {
251+
var r = await send('ui/initialize', {
252+
protocolVersion:'2026-01-26',
253+
appCapabilities:{},
254+
clientInfo:{name:'Scenario Modeler',version:'1.0.0'}
255+
});
256+
hostCtx = r.hostContext || {};
257+
window.parent.postMessage({jsonrpc:'2.0',method:'ui/notifications/initialized',params:{}},'*');
258+
259+
onNotif('ui/notifications/tool-result', function(params) {
260+
var data = params.structuredContent || {};
261+
if (data.templates) templates = data.templates;
262+
if (data.defaultInputs) defaultInputs = data.defaultInputs;
263+
if (defaultInputs && !data.customInputs) currentInputs = Object.assign({}, defaultInputs);
264+
render();
265+
});
266+
267+
render();
268+
}
269+
270+
init().catch(function(e){ document.getElementById('loading').textContent = 'Error: ' + e.message; });
271+
</script>
272+
</body>
273+
</html>

0 commit comments

Comments
 (0)