Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/budget-allocator-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export function createServer(): McpServer {
{
title: "Get Budget Data",
description:
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage. The widget is interactive and exposes tools for reading/modifying allocations, adjusting budgets, and comparing against industry benchmarks.",
inputSchema: {},
outputSchema: BudgetDataResponseSchema,
_meta: { ui: { resourceUri } },
Expand Down
290 changes: 290 additions & 0 deletions examples/budget-allocator-server/src/mcp-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { Chart, registerables } from "chart.js";
import { z } from "zod";
import "./global.css";
import "./mcp-app.css";

Expand Down Expand Up @@ -626,6 +627,295 @@ function handleHostContextChanged(ctx: McpUiHostContext) {

app.onhostcontextchanged = handleHostContextChanged;

// Register tools for model interaction
app.registerTool(
"get-allocations",
{
title: "Get Budget Allocations",
description:
"Get the current budget allocations including total budget, percentages, and amounts per category",
},
async () => {
if (!state.config) {
return {
content: [
{ type: "text" as const, text: "Error: Configuration not loaded" },
],
isError: true,
};
}

const allocations: Record<string, { percent: number; amount: number }> = {};
for (const category of state.config.categories) {
const percent = state.allocations.get(category.id) ?? 0;
allocations[category.id] = {
percent,
amount: (percent / 100) * state.totalBudget,
};
}

const result = {
totalBudget: state.totalBudget,
currency: state.config.currency,
currencySymbol: state.config.currencySymbol,
selectedStage: state.selectedStage,
allocations,
categories: state.config.categories.map((c) => ({
id: c.id,
name: c.name,
color: c.color,
})),
};

return {
content: [
{ type: "text" as const, text: JSON.stringify(result, null, 2) },
],
structuredContent: result,
};
},
);

app.registerTool(
"set-allocation",
{
title: "Set Category Allocation",
description: "Set the allocation percentage for a specific budget category",
inputSchema: z.object({
categoryId: z
.string()
.describe(
"Category ID (e.g., 'rd', 'sales', 'marketing', 'ops', 'ga')",
),
percent: z
.number()
.min(0)
.max(100)
.describe("Allocation percentage (0-100)"),
}),
},
async (args) => {
if (!state.config) {
return {
content: [
{ type: "text" as const, text: "Error: Configuration not loaded" },
],
isError: true,
};
}

const category = state.config.categories.find(
(c) => c.id === args.categoryId,
);
if (!category) {
return {
content: [
{
type: "text" as const,
text: `Error: Category "${args.categoryId}" not found. Available: ${state.config.categories.map((c) => c.id).join(", ")}`,
},
],
isError: true,
};
}

handleSliderChange(args.categoryId, args.percent);

// Also update the slider UI
const slider = document.querySelector(
`.slider-row[data-category-id="${args.categoryId}"] .slider`,
) as HTMLInputElement | null;
if (slider) {
slider.value = String(args.percent);
}

const amount = (args.percent / 100) * state.totalBudget;
return {
content: [
{
type: "text" as const,
text: `Set ${category.name} allocation to ${args.percent.toFixed(1)}% (${state.config.currencySymbol}${amount.toLocaleString()})`,
},
],
};
},
);

app.registerTool(
"set-total-budget",
{
title: "Set Total Budget",
description: "Set the total budget amount",
inputSchema: z.object({
amount: z.number().positive().describe("Total budget amount"),
}),
},
async (args) => {
if (!state.config) {
return {
content: [
{ type: "text" as const, text: "Error: Configuration not loaded" },
],
isError: true,
};
}

state.totalBudget = args.amount;

// Update the budget selector if this amount is a preset
const budgetSelector = document.getElementById(
"budget-selector",
) as HTMLSelectElement | null;
if (budgetSelector) {
const option = Array.from(budgetSelector.options).find(
(opt) => parseInt(opt.value) === args.amount,
);
if (option) {
budgetSelector.value = String(args.amount);
}
}

updateAllSliderAmounts();
updateStatusBar();
updateComparisonSummary();

return {
content: [
{
type: "text" as const,
text: `Total budget set to ${state.config.currencySymbol}${args.amount.toLocaleString()}`,
},
],
};
},
);

app.registerTool(
"set-company-stage",
{
title: "Set Company Stage",
description:
"Set the company stage for benchmark comparison (seed, series_a, series_b, growth)",
inputSchema: z.object({
stage: z.string().describe("Company stage ID"),
}),
},
async (args) => {
if (!state.analytics) {
return {
content: [
{ type: "text" as const, text: "Error: Analytics not loaded" },
],
isError: true,
};
}

if (!state.analytics.stages.includes(args.stage)) {
return {
content: [
{
type: "text" as const,
text: `Error: Stage "${args.stage}" not found. Available: ${state.analytics.stages.join(", ")}`,
},
],
isError: true,
};
}

state.selectedStage = args.stage;

// Update the stage selector UI
const stageSelector = document.getElementById(
"stage-selector",
) as HTMLSelectElement | null;
if (stageSelector) {
stageSelector.value = args.stage;
}

// Update all badges and summary
if (state.config) {
for (const category of state.config.categories) {
updatePercentileBadge(category.id);
}
updateComparisonSummary();
}

return {
content: [
{
type: "text" as const,
text: `Company stage set to "${args.stage}"`,
},
],
};
},
);

app.registerTool(
"get-benchmark-comparison",
{
title: "Get Benchmark Comparison",
description:
"Compare current allocations against industry benchmarks for the selected stage",
},
async () => {
if (!state.config || !state.analytics) {
return {
content: [{ type: "text" as const, text: "Error: Data not loaded" }],
isError: true,
};
}

const benchmark = state.analytics.benchmarks.find(
(b) => b.stage === state.selectedStage,
);
if (!benchmark) {
return {
content: [
{
type: "text" as const,
text: `Error: No benchmark data for stage "${state.selectedStage}"`,
},
],
isError: true,
};
}

const comparison: Record<
string,
{ current: number; p25: number; p50: number; p75: number; status: string }
> = {};

for (const category of state.config.categories) {
const current = state.allocations.get(category.id) ?? 0;
const benchmarkData = benchmark.categoryBenchmarks[category.id];
let status = "within range";
if (current < benchmarkData.p25) status = "below p25";
else if (current > benchmarkData.p75) status = "above p75";

comparison[category.id] = {
current,
p25: benchmarkData.p25,
p50: benchmarkData.p50,
p75: benchmarkData.p75,
status,
};
}

const result = {
stage: state.selectedStage,
comparison,
};

return {
content: [
{ type: "text" as const, text: JSON.stringify(result, null, 2) },
],
structuredContent: result,
};
},
);

// Handle theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
Expand Down
2 changes: 1 addition & 1 deletion examples/map-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export function createServer(): McpServer {
{
title: "Show Map",
description:
"Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.",
"Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location. The widget is interactive and exposes tools for navigation (fly to locations) and querying the current view.",
inputSchema: {
west: z
.number()
Expand Down
Loading
Loading