Skip to content

Commit 3e6545a

Browse files
committed
feat(shadertoy-server): Add widget interaction tools
1 parent c9f8c8e commit 3e6545a

File tree

3 files changed

+660
-10
lines changed

3 files changed

+660
-10
lines changed

examples/budget-allocator-server/src/mcp-app.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
55
import { Chart, registerables } from "chart.js";
6+
import { z } from "zod";
67
import "./global.css";
78
import "./mcp-app.css";
89

@@ -626,6 +627,294 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
626627

627628
app.onhostcontextchanged = handleHostContextChanged;
628629

630+
// Register tools for model interaction
631+
app.registerTool(
632+
"get-allocations",
633+
{
634+
title: "Get Budget Allocations",
635+
description:
636+
"Get the current budget allocations including total budget, percentages, and amounts per category",
637+
},
638+
async () => {
639+
if (!state.config) {
640+
return {
641+
content: [
642+
{ type: "text" as const, text: "Error: Configuration not loaded" },
643+
],
644+
isError: true,
645+
};
646+
}
647+
648+
const allocations: Record<string, { percent: number; amount: number }> = {};
649+
for (const category of state.config.categories) {
650+
const percent = state.allocations.get(category.id) ?? 0;
651+
allocations[category.id] = {
652+
percent,
653+
amount: (percent / 100) * state.totalBudget,
654+
};
655+
}
656+
657+
const result = {
658+
totalBudget: state.totalBudget,
659+
currency: state.config.currency,
660+
currencySymbol: state.config.currencySymbol,
661+
selectedStage: state.selectedStage,
662+
allocations,
663+
categories: state.config.categories.map((c) => ({
664+
id: c.id,
665+
name: c.name,
666+
color: c.color,
667+
})),
668+
};
669+
670+
return {
671+
content: [
672+
{ type: "text" as const, text: JSON.stringify(result, null, 2) },
673+
],
674+
structuredContent: result,
675+
};
676+
},
677+
);
678+
679+
app.registerTool(
680+
"set-allocation",
681+
{
682+
title: "Set Category Allocation",
683+
description:
684+
"Set the allocation percentage for a specific budget category",
685+
inputSchema: z.object({
686+
categoryId: z
687+
.string()
688+
.describe("Category ID (e.g., 'rd', 'sales', 'marketing', 'ops', 'ga')"),
689+
percent: z
690+
.number()
691+
.min(0)
692+
.max(100)
693+
.describe("Allocation percentage (0-100)"),
694+
}),
695+
},
696+
async (args) => {
697+
if (!state.config) {
698+
return {
699+
content: [
700+
{ type: "text" as const, text: "Error: Configuration not loaded" },
701+
],
702+
isError: true,
703+
};
704+
}
705+
706+
const category = state.config.categories.find((c) => c.id === args.categoryId);
707+
if (!category) {
708+
return {
709+
content: [
710+
{
711+
type: "text" as const,
712+
text: `Error: Category "${args.categoryId}" not found. Available: ${state.config.categories.map((c) => c.id).join(", ")}`,
713+
},
714+
],
715+
isError: true,
716+
};
717+
}
718+
719+
handleSliderChange(args.categoryId, args.percent);
720+
721+
// Also update the slider UI
722+
const slider = document.querySelector(
723+
`.slider-row[data-category-id="${args.categoryId}"] .slider`,
724+
) as HTMLInputElement | null;
725+
if (slider) {
726+
slider.value = String(args.percent);
727+
}
728+
729+
const amount = (args.percent / 100) * state.totalBudget;
730+
return {
731+
content: [
732+
{
733+
type: "text" as const,
734+
text: `Set ${category.name} allocation to ${args.percent.toFixed(1)}% (${state.config.currencySymbol}${amount.toLocaleString()})`,
735+
},
736+
],
737+
};
738+
},
739+
);
740+
741+
app.registerTool(
742+
"set-total-budget",
743+
{
744+
title: "Set Total Budget",
745+
description: "Set the total budget amount",
746+
inputSchema: z.object({
747+
amount: z.number().positive().describe("Total budget amount"),
748+
}),
749+
},
750+
async (args) => {
751+
if (!state.config) {
752+
return {
753+
content: [
754+
{ type: "text" as const, text: "Error: Configuration not loaded" },
755+
],
756+
isError: true,
757+
};
758+
}
759+
760+
state.totalBudget = args.amount;
761+
762+
// Update the budget selector if this amount is a preset
763+
const budgetSelector = document.getElementById(
764+
"budget-selector",
765+
) as HTMLSelectElement | null;
766+
if (budgetSelector) {
767+
const option = Array.from(budgetSelector.options).find(
768+
(opt) => parseInt(opt.value) === args.amount,
769+
);
770+
if (option) {
771+
budgetSelector.value = String(args.amount);
772+
}
773+
}
774+
775+
updateAllSliderAmounts();
776+
updateStatusBar();
777+
updateComparisonSummary();
778+
779+
return {
780+
content: [
781+
{
782+
type: "text" as const,
783+
text: `Total budget set to ${state.config.currencySymbol}${args.amount.toLocaleString()}`,
784+
},
785+
],
786+
};
787+
},
788+
);
789+
790+
app.registerTool(
791+
"set-company-stage",
792+
{
793+
title: "Set Company Stage",
794+
description:
795+
"Set the company stage for benchmark comparison (seed, series_a, series_b, growth)",
796+
inputSchema: z.object({
797+
stage: z.string().describe("Company stage ID"),
798+
}),
799+
},
800+
async (args) => {
801+
if (!state.analytics) {
802+
return {
803+
content: [
804+
{ type: "text" as const, text: "Error: Analytics not loaded" },
805+
],
806+
isError: true,
807+
};
808+
}
809+
810+
if (!state.analytics.stages.includes(args.stage)) {
811+
return {
812+
content: [
813+
{
814+
type: "text" as const,
815+
text: `Error: Stage "${args.stage}" not found. Available: ${state.analytics.stages.join(", ")}`,
816+
},
817+
],
818+
isError: true,
819+
};
820+
}
821+
822+
state.selectedStage = args.stage;
823+
824+
// Update the stage selector UI
825+
const stageSelector = document.getElementById(
826+
"stage-selector",
827+
) as HTMLSelectElement | null;
828+
if (stageSelector) {
829+
stageSelector.value = args.stage;
830+
}
831+
832+
// Update all badges and summary
833+
if (state.config) {
834+
for (const category of state.config.categories) {
835+
updatePercentileBadge(category.id);
836+
}
837+
updateComparisonSummary();
838+
}
839+
840+
return {
841+
content: [
842+
{
843+
type: "text" as const,
844+
text: `Company stage set to "${args.stage}"`,
845+
},
846+
],
847+
};
848+
},
849+
);
850+
851+
app.registerTool(
852+
"get-benchmark-comparison",
853+
{
854+
title: "Get Benchmark Comparison",
855+
description:
856+
"Compare current allocations against industry benchmarks for the selected stage",
857+
},
858+
async () => {
859+
if (!state.config || !state.analytics) {
860+
return {
861+
content: [
862+
{ type: "text" as const, text: "Error: Data not loaded" },
863+
],
864+
isError: true,
865+
};
866+
}
867+
868+
const benchmark = state.analytics.benchmarks.find(
869+
(b) => b.stage === state.selectedStage,
870+
);
871+
if (!benchmark) {
872+
return {
873+
content: [
874+
{
875+
type: "text" as const,
876+
text: `Error: No benchmark data for stage "${state.selectedStage}"`,
877+
},
878+
],
879+
isError: true,
880+
};
881+
}
882+
883+
const comparison: Record<
884+
string,
885+
{ current: number; p25: number; p50: number; p75: number; status: string }
886+
> = {};
887+
888+
for (const category of state.config.categories) {
889+
const current = state.allocations.get(category.id) ?? 0;
890+
const benchmarkData = benchmark.categoryBenchmarks[category.id];
891+
let status = "within range";
892+
if (current < benchmarkData.p25) status = "below p25";
893+
else if (current > benchmarkData.p75) status = "above p75";
894+
895+
comparison[category.id] = {
896+
current,
897+
p25: benchmarkData.p25,
898+
p50: benchmarkData.p50,
899+
p75: benchmarkData.p75,
900+
status,
901+
};
902+
}
903+
904+
const result = {
905+
stage: state.selectedStage,
906+
comparison,
907+
};
908+
909+
return {
910+
content: [
911+
{ type: "text" as const, text: JSON.stringify(result, null, 2) },
912+
],
913+
structuredContent: result,
914+
};
915+
},
916+
);
917+
629918
// Handle theme changes
630919
window
631920
.matchMedia("(prefers-color-scheme: dark)")

0 commit comments

Comments
 (0)