Skip to content

Commit 9f09932

Browse files
authored
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
1 parent 366aa9b commit 9f09932

19 files changed

Lines changed: 219 additions & 124 deletions

File tree

examples/basic-server-vanillajs/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
33
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
44
import fs from "node:fs/promises";
55
import path from "node:path";
6+
import { z } from "zod";
67
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps/server";
78
import { startServer } from "./server-utils.js";
89

@@ -28,12 +29,16 @@ export function createServer(): McpServer {
2829
title: "Get Time",
2930
description: "Returns the current server time as an ISO 8601 string.",
3031
inputSchema: {},
32+
outputSchema: z.object({
33+
time: z.string(),
34+
}),
3135
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
3236
},
3337
async (): Promise<CallToolResult> => {
3438
const time = new Date().toISOString();
3539
return {
36-
content: [{ type: "text", text: JSON.stringify({ time }) }],
40+
content: [{ type: "text", text: time }],
41+
structuredContent: { time },
3742
};
3843
},
3944
);

examples/basic-server-vanillajs/src/mcp-app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const log = {
1515

1616

1717
function extractTime(result: CallToolResult): string {
18-
const { text } = result.content?.find((c) => c.type === "text")!;
19-
return text;
18+
const { time } = (result.structuredContent as { time?: string }) ?? {};
19+
return time ?? "[ERROR]";
2020
}
2121

2222

examples/budget-allocator-server/server.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,30 @@ function generateHistory(
224224
return months;
225225
}
226226

227+
// ---------------------------------------------------------------------------
228+
// Response Formatting
229+
// ---------------------------------------------------------------------------
230+
231+
function formatBudgetSummary(data: BudgetDataResponse): string {
232+
const lines: string[] = [
233+
"Budget Allocator Configuration",
234+
"==============================",
235+
"",
236+
`Default Budget: ${data.config.currencySymbol}${data.config.defaultBudget.toLocaleString()}`,
237+
`Available Presets: ${data.config.presetBudgets.map((b) => `${data.config.currencySymbol}${b.toLocaleString()}`).join(", ")}`,
238+
"",
239+
"Categories:",
240+
...data.config.categories.map(
241+
(c) => ` - ${c.name}: ${c.defaultPercent}% default`,
242+
),
243+
"",
244+
`Historical Data: ${data.analytics.history.length} months`,
245+
`Benchmark Stages: ${data.analytics.stages.join(", ")}`,
246+
`Default Stage: ${data.analytics.defaultStage}`,
247+
];
248+
return lines.join("\n");
249+
}
250+
227251
// ---------------------------------------------------------------------------
228252
// MCP Server Setup
229253
// ---------------------------------------------------------------------------
@@ -248,6 +272,7 @@ export function createServer(): McpServer {
248272
description:
249273
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
250274
inputSchema: {},
275+
outputSchema: BudgetDataResponseSchema,
251276
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
252277
},
253278
async (): Promise<CallToolResult> => {
@@ -276,9 +301,10 @@ export function createServer(): McpServer {
276301
content: [
277302
{
278303
type: "text",
279-
text: JSON.stringify(response),
304+
text: formatBudgetSummary(response),
280305
},
281306
],
307+
structuredContent: response,
282308
};
283309
},
284310
);

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -607,13 +607,7 @@ const app = new App({ name: "Budget Allocator", version: "1.0.0" });
607607

608608
app.ontoolresult = (result) => {
609609
log.info("Received tool result:", result);
610-
const text = result
611-
.content!.filter(
612-
(c): c is { type: "text"; text: string } => c.type === "text",
613-
)
614-
.map((c) => c.text)
615-
.join("");
616-
const data = JSON.parse(text) as BudgetDataResponse;
610+
const data = result.structuredContent as unknown as BudgetDataResponse;
617611
if (data?.config && data?.analytics) {
618612
initializeUI(data.config, data.analytics);
619613
}

examples/cohort-heatmap-server/server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ function generateCohortData(
152152
};
153153
}
154154

155+
function formatCohortSummary(data: CohortData): string {
156+
const avgRetention = data.cohorts
157+
.flatMap((c) => c.cells)
158+
.filter((cell) => cell.periodIndex > 0)
159+
.reduce((sum, cell, _, arr) => sum + cell.retention / arr.length, 0);
160+
161+
return `Cohort Analysis: ${data.cohorts.length} cohorts, ${data.periods.length} periods
162+
Average retention: ${(avgRetention * 100).toFixed(1)}%
163+
Metric: ${data.metric}, Period: ${data.periodType}`;
164+
}
165+
155166
export function createServer(): McpServer {
156167
const server = new McpServer({
157168
name: "Cohort Heatmap Server",
@@ -169,6 +180,7 @@ export function createServer(): McpServer {
169180
description:
170181
"Returns cohort retention heatmap data showing customer retention over time by signup month",
171182
inputSchema: GetCohortDataInputSchema.shape,
183+
outputSchema: CohortDataSchema.shape,
172184
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
173185
},
174186
async ({ metric, periodType, cohortCount, maxPeriods }) => {
@@ -180,7 +192,8 @@ export function createServer(): McpServer {
180192
);
181193

182194
return {
183-
content: [{ type: "text", text: JSON.stringify(data) }],
195+
content: [{ type: "text", text: formatCohortSummary(data) }],
196+
structuredContent: data,
184197
};
185198
},
186199
);

examples/cohort-heatmap-server/src/mcp-app.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,7 @@ function CohortHeatmapInner({
123123
maxPeriods: 12,
124124
},
125125
});
126-
const text = result
127-
.content!.filter(
128-
(c): c is { type: "text"; text: string } => c.type === "text",
129-
)
130-
.map((c) => c.text)
131-
.join("");
132-
setData(JSON.parse(text) as CohortData);
126+
setData(result.structuredContent as unknown as CohortData);
133127
} catch (e) {
134128
console.error("Failed to fetch cohort data:", e);
135129
} finally {

examples/customer-segmentation-server/server.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,29 @@ const GetCustomerDataInputSchema = z.object({
3030
.describe("Filter by segment (default: All)"),
3131
});
3232

33+
const CustomerSchema = z.object({
34+
id: z.string(),
35+
name: z.string(),
36+
segment: z.string(),
37+
annualRevenue: z.number(),
38+
employeeCount: z.number(),
39+
accountAge: z.number(),
40+
engagementScore: z.number(),
41+
supportTickets: z.number(),
42+
nps: z.number(),
43+
});
44+
45+
const SegmentSummarySchema = z.object({
46+
name: z.string(),
47+
count: z.number(),
48+
color: z.string(),
49+
});
50+
51+
const GetCustomerDataOutputSchema = z.object({
52+
customers: z.array(CustomerSchema),
53+
segments: z.array(SegmentSummarySchema),
54+
});
55+
3356
// Cache generated data for session consistency
3457
let cachedCustomers: Customer[] | null = null;
3558
let cachedSegments: SegmentSummary[] | null = null;
@@ -78,13 +101,15 @@ export function createServer(): McpServer {
78101
description:
79102
"Returns customer data with segment information for visualization. Optionally filter by segment.",
80103
inputSchema: GetCustomerDataInputSchema.shape,
104+
outputSchema: GetCustomerDataOutputSchema.shape,
81105
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
82106
},
83107
async ({ segment }): Promise<CallToolResult> => {
84108
const data = getCustomerData(segment);
85109

86110
return {
87111
content: [{ type: "text", text: JSON.stringify(data) }],
112+
structuredContent: data,
88113
};
89114
},
90115
);

examples/integration-server/server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from "@modelcontextprotocol/sdk/types.js";
77
import fs from "node:fs/promises";
88
import path from "node:path";
9+
import { z } from "zod";
910
import {
1011
registerAppTool,
1112
registerAppResource,
@@ -33,16 +34,21 @@ export function createServer(): McpServer {
3334
title: "Get Time",
3435
description: "Returns the current server time.",
3536
inputSchema: {},
37+
outputSchema: z.object({
38+
time: z.string(),
39+
}),
3640
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
3741
},
3842
async (): Promise<CallToolResult> => {
43+
const time = new Date().toISOString();
3944
return {
4045
content: [
4146
{
4247
type: "text",
43-
text: JSON.stringify({ time: new Date().toISOString() }),
48+
text: JSON.stringify({ time }),
4449
},
4550
],
51+
structuredContent: { time },
4652
};
4753
},
4854
);

examples/integration-server/src/mcp-app.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,7 @@ const log = {
1717
};
1818

1919
function extractTime(callToolResult: CallToolResult): string {
20-
const text = callToolResult
21-
.content!.filter(
22-
(c): c is { type: "text"; text: string } => c.type === "text",
23-
)
24-
.map((c) => c.text)
25-
.join("");
26-
const { time } = JSON.parse(text) as { time: string };
20+
const { time } = callToolResult.structuredContent as { time: string };
2721
return time;
2822
}
2923

examples/scenario-modeler-server/server.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ const GetScenarioDataInputSchema = z.object({
6464
),
6565
});
6666

67+
const GetScenarioDataOutputSchema = z.object({
68+
templates: z.array(ScenarioTemplateSchema),
69+
defaultInputs: ScenarioInputsSchema,
70+
customProjections: z.array(MonthlyProjectionSchema).optional(),
71+
customSummary: ScenarioSummarySchema.optional(),
72+
});
73+
6774
// Types derived from schemas
6875
type ScenarioInputs = z.infer<typeof ScenarioInputsSchema>;
6976
type MonthlyProjection = z.infer<typeof MonthlyProjectionSchema>;
@@ -243,6 +250,37 @@ const DEFAULT_INPUTS: ScenarioInputs = {
243250
fixedCosts: 30000,
244251
};
245252

253+
// ============================================================================
254+
// Formatters for text output
255+
// ============================================================================
256+
257+
function formatCurrency(value: number): string {
258+
const absValue = Math.abs(value);
259+
const sign = value < 0 ? "-" : "";
260+
if (absValue >= 1_000_000) {
261+
return `${sign}$${(absValue / 1_000_000).toFixed(2)}M`;
262+
}
263+
if (absValue >= 1_000) {
264+
return `${sign}$${(absValue / 1_000).toFixed(1)}K`;
265+
}
266+
return `${sign}$${Math.round(absValue)}`;
267+
}
268+
269+
function formatScenarioSummary(
270+
summary: ScenarioSummary,
271+
label: string,
272+
): string {
273+
return [
274+
`${label}:`,
275+
` Ending MRR: ${formatCurrency(summary.endingMRR)}`,
276+
` ARR: ${formatCurrency(summary.arr)}`,
277+
` Total Revenue: ${formatCurrency(summary.totalRevenue)}`,
278+
` Total Profit: ${formatCurrency(summary.totalProfit)}`,
279+
` MRR Growth: ${summary.mrrGrowthPct.toFixed(1)}%`,
280+
` Break-even: ${summary.breakEvenMonth ? `Month ${summary.breakEvenMonth}` : "Not achieved"}`,
281+
].join("\n");
282+
}
283+
246284
// ============================================================================
247285
// MCP Server
248286
// ============================================================================
@@ -269,6 +307,7 @@ export function createServer(): McpServer {
269307
description:
270308
"Returns SaaS scenario templates and optionally computes custom projections for given inputs",
271309
inputSchema: GetScenarioDataInputSchema.shape,
310+
outputSchema: GetScenarioDataOutputSchema.shape,
272311
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
273312
},
274313
async (args: {
@@ -278,18 +317,28 @@ export function createServer(): McpServer {
278317
? calculateScenario(args.customInputs)
279318
: undefined;
280319

320+
const text = [
321+
"SaaS Scenario Modeler",
322+
"=".repeat(40),
323+
"",
324+
"Available Templates:",
325+
...SCENARIO_TEMPLATES.map(
326+
(t) => ` ${t.icon} ${t.name}: ${t.description}`,
327+
),
328+
"",
329+
customScenario
330+
? formatScenarioSummary(customScenario.summary, "Custom Scenario")
331+
: "Use customInputs parameter to compute projections for a specific scenario.",
332+
].join("\n");
333+
281334
return {
282-
content: [
283-
{
284-
type: "text",
285-
text: JSON.stringify({
286-
templates: SCENARIO_TEMPLATES,
287-
defaultInputs: DEFAULT_INPUTS,
288-
customProjections: customScenario?.projections,
289-
customSummary: customScenario?.summary,
290-
}),
291-
},
292-
],
335+
content: [{ type: "text", text }],
336+
structuredContent: {
337+
templates: SCENARIO_TEMPLATES,
338+
defaultInputs: DEFAULT_INPUTS,
339+
customProjections: customScenario?.projections,
340+
customSummary: customScenario?.summary,
341+
},
293342
};
294343
},
295344
);

0 commit comments

Comments
 (0)