Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
331d1fd
feat: Add tool registration and bidirectional tool support
ochafik Dec 2, 2025
6b5a5c1
nits
ochafik Dec 3, 2025
a015235
Update apps.mdx
ochafik Dec 3, 2025
afdd973
feat: Add automatic request handlers for app tool registration
ochafik Dec 3, 2025
5e40620
type updates
ochafik Dec 3, 2025
0781674
fix: Ensure tools/list returns valid JSON Schema for all tools
ochafik Dec 3, 2025
81a9b43
type updates
ochafik Dec 3, 2025
04f38e0
rm zod-to-json-schema
ochafik Dec 3, 2025
4024038
fix: Update RegisteredTool to use 'handler' instead of 'callback' (SD…
ochafik Jan 9, 2026
b705c14
refactor: Rename sendCallTool/sendListTools to callTool/listTools
ochafik Jan 9, 2026
4b23e1e
feat: Add screenshot and click support to SDK
ochafik Jan 17, 2026
e18d51b
feat(map-server): Enable registerTool with navigate-to and get-curren…
ochafik Jan 17, 2026
c9f8c8e
feat(pdf-server): Add widget interaction tools
ochafik Jan 17, 2026
3e6545a
feat(shadertoy-server): Add widget interaction tools
ochafik Jan 17, 2026
8f592a3
feat(examples): Add widget interaction tools to budget-allocator, sha…
ochafik Jan 17, 2026
ae4157b
feat: Add expand-node tool to wiki-explorer and update tool descriptions
ochafik Jan 17, 2026
c8ff8c1
refactor: Simplify tool descriptions - remove client implementation d…
ochafik Jan 17, 2026
2b55aa7
Merge branch 'main' into feat/app-tool-registration
ochafik Jan 20, 2026
c7568bd
Merge branch 'main' into feat/app-tool-registration
ochafik Jan 21, 2026
4d9222e
Merge branch 'main' into feat/app-tool-registration
ochafik Jan 28, 2026
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