feat: use structuredContent with outputSchema in example servers (#220)

* build app-with-deps

* make registerAppTool more interchangeable w/ server.registerTool

* feat: use structuredContent in all example servers

All servers now return structuredContent alongside JSON text content.
All mcp-apps now read structuredContent directly (no JSON.parse fallback).

Updated servers:
- basic-server-vanillajs
- budget-allocator-server
- cohort-heatmap-server
- customer-segmentation-server
- integration-server
- scenario-modeler-server
- system-monitor-server
- threejs-server
- video-resource-server
- wiki-explorer-server

* feat: add outputSchema to all example servers

Define output schemas for structuredContent validation:
- basic-server-vanillajs: { time: string }
- budget-allocator-server: BudgetDataResponseSchema
- cohort-heatmap-server: CohortDataSchema
- customer-segmentation-server: customers/segments schema
- integration-server: { time: string }
- scenario-modeler-server: templates/defaultInputs schema
- system-monitor-server: SystemStatsSchema
- threejs-server: { code, height }
- video-resource-server: { videoUri, description }
- wiki-explorer-server: { page, links, error }

* prettier:fix

* make registerAppTool more interchangeable w/ server.registerTool

* feat: use structuredContent in all example servers

All servers now return structuredContent alongside JSON text content.
All mcp-apps now read structuredContent directly (no JSON.parse fallback).

Updated servers:
- basic-server-vanillajs
- budget-allocator-server
- cohort-heatmap-server
- customer-segmentation-server
- integration-server
- scenario-modeler-server
- system-monitor-server
- threejs-server
- video-resource-server
- wiki-explorer-server

* feat: add outputSchema to all example servers

Define output schemas for structuredContent validation:
- basic-server-vanillajs: { time: string }
- budget-allocator-server: BudgetDataResponseSchema
- cohort-heatmap-server: CohortDataSchema
- customer-segmentation-server: customers/segments schema
- integration-server: { time: string }
- scenario-modeler-server: templates/defaultInputs schema
- system-monitor-server: SystemStatsSchema
- threejs-server: { code, height }
- video-resource-server: { videoUri, description }
- wiki-explorer-server: { page, links, error }

* prettier:fix

* revert: remove unrelated workflow, version, and arch changes

* chore: update package-lock.json

* refactor(basic-server-vanillajs): address PR review comments

- Simplify server.ts to return plain time string in text content (not JSON),
  matching the quickstart guide pattern
- Add defensive coding in mcp-app.ts with optional chaining and fallback
  to handle potential type mismatches gracefully

* refactor: restore rich text formatting for LLM-readable content

Restore the human-readable text formatting functions that were removed,
providing meaningful summaries in the text content while keeping
structuredContent for programmatic UI consumption:

- budget-allocator-server: formatBudgetSummary with config overview
- cohort-heatmap-server: formatCohortSummary with retention stats
- scenario-modeler-server: formatScenarioSummary with financial metrics
This commit is contained in:
Olivier Chafik 2026-01-12 14:25:16 +00:00 • committed by GitHub
commit 9f09932baf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 219 additions and 124 deletions

View file

@ -3,6 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps/server";
import { startServer } from "./server-utils.js";
@ -28,12 +29,16 @@ export function createServer(): McpServer {
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
outputSchema: z.object({
time: z.string(),
}),
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
},
async (): Promise<CallToolResult> => {
const time = new Date().toISOString();
return {
content: [{ type: "text", text: JSON.stringify({ time }) }],
content: [{ type: "text", text: time }],
structuredContent: { time },
};
},
);

View file

@ -15,8 +15,8 @@ const log = {
function extractTime(result: CallToolResult): string {
const { text } = result.content?.find((c) => c.type === "text")!;
return text;
const { time } = (result.structuredContent as { time?: string }) ?? {};
return time ?? "[ERROR]";
}

View file

@ -224,6 +224,30 @@ function generateHistory(
return months;
}
// ---------------------------------------------------------------------------
// Response Formatting
// ---------------------------------------------------------------------------
function formatBudgetSummary(data: BudgetDataResponse): string {
const lines: string[] = [
"Budget Allocator Configuration",
"==============================",
"",
`Default Budget: ${data.config.currencySymbol}${data.config.defaultBudget.toLocaleString()}`,
`Available Presets: ${data.config.presetBudgets.map((b) => `${data.config.currencySymbol}${b.toLocaleString()}`).join(", ")}`,
"",
"Categories:",
...data.config.categories.map(
(c) => ` - ${c.name}: ${c.defaultPercent}% default`,
),
"",
`Historical Data: ${data.analytics.history.length} months`,
`Benchmark Stages: ${data.analytics.stages.join(", ")}`,
`Default Stage: ${data.analytics.defaultStage}`,
];
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// MCP Server Setup
// ---------------------------------------------------------------------------
@ -248,6 +272,7 @@ export function createServer(): McpServer {
description:
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
inputSchema: {},
outputSchema: BudgetDataResponseSchema,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async (): Promise<CallToolResult> => {
@ -276,9 +301,10 @@ export function createServer(): McpServer {
content: [
{
type: "text",
text: JSON.stringify(response),
text: formatBudgetSummary(response),
},
],
structuredContent: response,
};
},
);

View file

@ -607,13 +607,7 @@ const app = new App({ name: "Budget Allocator", version: "1.0.0" });
app.ontoolresult = (result) => {
log.info("Received tool result:", result);
const text = result
.content!.filter(
(c): c is { type: "text"; text: string } => c.type === "text",
)
.map((c) => c.text)
.join("");
const data = JSON.parse(text) as BudgetDataResponse;
const data = result.structuredContent as unknown as BudgetDataResponse;
if (data?.config && data?.analytics) {
initializeUI(data.config, data.analytics);
}

View file

@ -152,6 +152,17 @@ function generateCohortData(
};
}
function formatCohortSummary(data: CohortData): string {
const avgRetention = data.cohorts
.flatMap((c) => c.cells)
.filter((cell) => cell.periodIndex > 0)
.reduce((sum, cell, _, arr) => sum + cell.retention / arr.length, 0);
return `Cohort Analysis: ${data.cohorts.length} cohorts, ${data.periods.length} periods
Average retention: ${(avgRetention * 100).toFixed(1)}%
Metric: ${data.metric}, Period: ${data.periodType}`;
}
export function createServer(): McpServer {
const server = new McpServer({
name: "Cohort Heatmap Server",
@ -169,6 +180,7 @@ export function createServer(): McpServer {
description:
"Returns cohort retention heatmap data showing customer retention over time by signup month",
inputSchema: GetCohortDataInputSchema.shape,
outputSchema: CohortDataSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async ({ metric, periodType, cohortCount, maxPeriods }) => {
@ -180,7 +192,8 @@ export function createServer(): McpServer {
);
return {
content: [{ type: "text", text: JSON.stringify(data) }],
content: [{ type: "text", text: formatCohortSummary(data) }],
structuredContent: data,
};
},
);

View file

@ -123,13 +123,7 @@ function CohortHeatmapInner({
maxPeriods: 12,
},
});
const text = result
.content!.filter(
(c): c is { type: "text"; text: string } => c.type === "text",
)
.map((c) => c.text)
.join("");
setData(JSON.parse(text) as CohortData);
setData(result.structuredContent as unknown as CohortData);
} catch (e) {
console.error("Failed to fetch cohort data:", e);
} finally {

View file

@ -30,6 +30,29 @@ const GetCustomerDataInputSchema = z.object({
.describe("Filter by segment (default: All)"),
});
const CustomerSchema = z.object({
id: z.string(),
name: z.string(),
segment: z.string(),
annualRevenue: z.number(),
employeeCount: z.number(),
accountAge: z.number(),
engagementScore: z.number(),
supportTickets: z.number(),
nps: z.number(),
});
const SegmentSummarySchema = z.object({
name: z.string(),
count: z.number(),
color: z.string(),
});
const GetCustomerDataOutputSchema = z.object({
customers: z.array(CustomerSchema),
segments: z.array(SegmentSummarySchema),
});
// Cache generated data for session consistency
let cachedCustomers: Customer[] | null = null;
let cachedSegments: SegmentSummary[] | null = null;
@ -78,6 +101,7 @@ export function createServer(): McpServer {
description:
"Returns customer data with segment information for visualization. Optionally filter by segment.",
inputSchema: GetCustomerDataInputSchema.shape,
outputSchema: GetCustomerDataOutputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async ({ segment }): Promise<CallToolResult> => {
@ -85,6 +109,7 @@ export function createServer(): McpServer {
return {
content: [{ type: "text", text: JSON.stringify(data) }],
structuredContent: data,
};
},
);

View file

@ -6,6 +6,7 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import {
registerAppTool,
registerAppResource,
@ -33,16 +34,21 @@ export function createServer(): McpServer {
title: "Get Time",
description: "Returns the current server time.",
inputSchema: {},
outputSchema: z.object({
time: z.string(),
}),
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
},
async (): Promise<CallToolResult> => {
const time = new Date().toISOString();
return {
content: [
{
type: "text",
text: JSON.stringify({ time: new Date().toISOString() }),
text: JSON.stringify({ time }),
},
],
structuredContent: { time },
};
},
);

View file

@ -17,13 +17,7 @@ const log = {
};
function extractTime(callToolResult: CallToolResult): string {
const text = callToolResult
.content!.filter(
(c): c is { type: "text"; text: string } => c.type === "text",
)
.map((c) => c.text)
.join("");
const { time } = JSON.parse(text) as { time: string };
const { time } = callToolResult.structuredContent as { time: string };
return time;
}

View file

@ -64,6 +64,13 @@ const GetScenarioDataInputSchema = z.object({
),
});
const GetScenarioDataOutputSchema = z.object({
templates: z.array(ScenarioTemplateSchema),
defaultInputs: ScenarioInputsSchema,
customProjections: z.array(MonthlyProjectionSchema).optional(),
customSummary: ScenarioSummarySchema.optional(),
});
// Types derived from schemas
type ScenarioInputs = z.infer<typeof ScenarioInputsSchema>;
type MonthlyProjection = z.infer<typeof MonthlyProjectionSchema>;
@ -243,6 +250,37 @@ const DEFAULT_INPUTS: ScenarioInputs = {
fixedCosts: 30000,
};
// ============================================================================
// Formatters for text output
// ============================================================================
function formatCurrency(value: number): string {
const absValue = Math.abs(value);
const sign = value < 0 ? "-" : "";
if (absValue >= 1_000_000) {
return `${sign}$${(absValue / 1_000_000).toFixed(2)}M`;
}
if (absValue >= 1_000) {
return `${sign}$${(absValue / 1_000).toFixed(1)}K`;
}
return `${sign}$${Math.round(absValue)}`;
}
function formatScenarioSummary(
summary: ScenarioSummary,
label: string,
): string {
return [
`${label}:`,
` Ending MRR: ${formatCurrency(summary.endingMRR)}`,
` ARR: ${formatCurrency(summary.arr)}`,
` Total Revenue: ${formatCurrency(summary.totalRevenue)}`,
` Total Profit: ${formatCurrency(summary.totalProfit)}`,
` MRR Growth: ${summary.mrrGrowthPct.toFixed(1)}%`,
` Break-even: ${summary.breakEvenMonth ? `Month ${summary.breakEvenMonth}` : "Not achieved"}`,
].join("\n");
}
// ============================================================================
// MCP Server
// ============================================================================
@ -269,6 +307,7 @@ export function createServer(): McpServer {
description:
"Returns SaaS scenario templates and optionally computes custom projections for given inputs",
inputSchema: GetScenarioDataInputSchema.shape,
outputSchema: GetScenarioDataOutputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async (args: {
@ -278,18 +317,28 @@ export function createServer(): McpServer {
? calculateScenario(args.customInputs)
: undefined;
const text = [
"SaaS Scenario Modeler",
"=".repeat(40),
"",
"Available Templates:",
...SCENARIO_TEMPLATES.map(
(t) => ` ${t.icon} ${t.name}: ${t.description}`,
),
"",
customScenario
? formatScenarioSummary(customScenario.summary, "Custom Scenario")
: "Use customInputs parameter to compute projections for a specific scenario.",
].join("\n");
return {
content: [
{
type: "text",
text: JSON.stringify({
templates: SCENARIO_TEMPLATES,
defaultInputs: DEFAULT_INPUTS,
customProjections: customScenario?.projections,
customSummary: customScenario?.summary,
}),
},
],
content: [{ type: "text", text }],
structuredContent: {
templates: SCENARIO_TEMPLATES,
defaultInputs: DEFAULT_INPUTS,
customProjections: customScenario?.projections,
customSummary: customScenario?.summary,
},
};
},
);

View file

@ -1,6 +1,5 @@
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState, useMemo, useCallback, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { SliderRow } from "./components/SliderRow.tsx";
@ -25,18 +24,6 @@ interface CallToolResultData {
defaultInputs?: ScenarioInputs;
}
/** Extract templates and defaultInputs from tool result content */
function extractResultData(result: CallToolResult): CallToolResultData {
const text = result
.content!.filter(
(c): c is { type: "text"; text: string } => c.type === "text",
)
.map((c) => c.text)
.join("");
const { templates, defaultInputs } = JSON.parse(text) as CallToolResultData;
return { templates, defaultInputs };
}
const APP_INFO = { name: "SaaS Scenario Modeler", version: "1.0.0" };
function getSafeAreaPaddingStyle(hostContext?: McpUiHostContext) {
@ -70,7 +57,8 @@ function ScenarioModeler() {
capabilities: {},
onAppCreated: (app) => {
app.ontoolresult = async (result) => {
const { templates, defaultInputs } = extractResultData(result);
const { templates, defaultInputs } =
result.structuredContent as unknown as CallToolResultData;
if (templates) setTemplates(templates);
if (defaultInputs) setDefaultInputs(defaultInputs);
};

View file

@ -124,6 +124,7 @@ export function createServer(): McpServer {
description:
"Returns current system statistics including per-core CPU usage, memory, and system info.",
inputSchema: {},
outputSchema: SystemStatsSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async (): Promise<CallToolResult> => {
@ -151,6 +152,7 @@ export function createServer(): McpServer {
return {
content: [{ type: "text", text: JSON.stringify(stats) }],
structuredContent: stats,
};
},
);

View file

@ -265,13 +265,7 @@ async function fetchStats(): Promise<void> {
arguments: {},
});
const text = result
.content!.filter(
(c): c is { type: "text"; text: string } => c.type === "text",
)
.map((c) => c.text)
.join("");
const stats = JSON.parse(text) as SystemStats;
const stats = result.structuredContent as unknown as SystemStats;
// Initialize chart on first data if needed
if (!state.chart && stats.cpu.count > 0) {

View file

@ -166,16 +166,17 @@ export function createServer(): McpServer {
.default(400)
.describe("Height in pixels"),
},
outputSchema: z.object({
code: z.string(),
height: z.number(),
}),
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async ({ code, height }) => {
const data = { code, height };
return {
content: [
{
type: "text",
text: JSON.stringify({ code, height }),
},
],
content: [{ type: "text", text: JSON.stringify(data) }],
structuredContent: data,
};
},
);

View file

@ -129,20 +129,21 @@ ${Object.entries(VIDEO_LIBRARY)
`Video ID to play. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`,
),
},
outputSchema: z.object({
videoUri: z.string(),
description: z.string(),
}),
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
},
async ({ videoId }): Promise<CallToolResult> => {
const video = VIDEO_LIBRARY[videoId];
const data = {
videoUri: `videos://${videoId}`,
description: video.description,
};
return {
content: [
{
type: "text",
text: JSON.stringify({
videoUri: `videos://${videoId}`,
description: video.description,
}),
},
],
content: [{ type: "text", text: JSON.stringify(data) }],
structuredContent: data,
};
},
);

View file

@ -28,18 +28,10 @@ const videoInfoEl = document.getElementById("video-info")!;
function parseToolResult(
result: CallToolResult,
): { videoUri: string; description: string } | null {
const text = result.content
?.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (!text) return null;
try {
return JSON.parse(text) as { videoUri: string; description: string };
} catch {
return null;
}
return result.structuredContent as {
videoUri: string;
description: string;
} | null;
}
// Show states

View file

@ -95,6 +95,19 @@ export function createServer(): McpServer {
.default("https://en.wikipedia.org/wiki/Model_Context_Protocol")
.describe("Wikipedia page URL"),
}),
outputSchema: z.object({
page: z.object({
url: z.string(),
title: z.string(),
}),
links: z.array(
z.object({
url: z.string(),
title: z.string(),
}),
),
error: z.string().nullable(),
}),
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async ({ url }): Promise<CallToolResult> => {
@ -121,11 +134,17 @@ export function createServer(): McpServer {
const links = extractWikiLinks(new URL(url), html);
const result = { page: { url, title }, links, error: null };
return { content: [{ type: "text", text: JSON.stringify(result) }] };
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
const result = { page: { url, title }, links: [], error };
return { content: [{ type: "text", text: JSON.stringify(result) }] };
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
}
},
);

View file

@ -322,44 +322,36 @@ app.ontoolresult = (result) => {
};
function handleToolResultData(result: CallToolResult): void {
if (
result.isError ||
!result.content?.[0] ||
result.content[0].type !== "text"
) {
if (result.isError) {
console.error("Tool result error:", result);
return;
}
try {
const response: ToolResponse = JSON.parse(result.content[0].text);
const { page, links, error } = response;
const response = result.structuredContent as unknown as ToolResponse;
const { page, links, error } = response;
// Ensure the source node exists
addNode(page.url, page.title);
updateNodeTitle(page.url, page.title);
// Ensure the source node exists
addNode(page.url, page.title);
updateNodeTitle(page.url, page.title);
if (error) {
setNodeState(page.url, "error", error);
} else {
// Get source node position so new nodes appear nearby
const sourceNode = graphData.nodes.find((n) => n.url === page.url);
const sourcePos = sourceNode
? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 }
: undefined;
if (error) {
setNodeState(page.url, "error", error);
} else {
// Get source node position so new nodes appear nearby
const sourceNode = graphData.nodes.find((n) => n.url === page.url);
const sourcePos = sourceNode
? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 }
: undefined;
// Add all linked nodes and edges
for (const link of links) {
addNode(link.url, link.title, "default", sourcePos);
addEdge(page.url, link.url);
}
setNodeState(page.url, "expanded");
// Add all linked nodes and edges
for (const link of links) {
addNode(link.url, link.title, "default", sourcePos);
addEdge(page.url, link.url);
}
updateGraph();
} catch (e) {
console.error("Failed to parse tool result:", e);
setNodeState(page.url, "expanded");
}
updateGraph();
}
app.onerror = (err) => {

12
package-lock.json generated
View file

@ -1635,9 +1635,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.7",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.8.tgz",
"integrity": "sha512-0/g2lIOPzX8f3vzW1ggQgvG5mjtFBDBHFAzI5SFAi2DzSqS9luJwqg9T6O/gKYLi+inS7eNxBeIFkkghIPvrMA==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@ -3358,9 +3358,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"version": "2.9.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
"integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
"dev": true,
"license": "Apache-2.0",
"bin": {