diff --git a/examples/basic-server-vanillajs/server.ts b/examples/basic-server-vanillajs/server.ts index 2717fc31..ef661a87 100644 --- a/examples/basic-server-vanillajs/server.ts +++ b/examples/basic-server-vanillajs/server.ts @@ -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 => { const time = new Date().toISOString(); return { - content: [{ type: "text", text: JSON.stringify({ time }) }], + content: [{ type: "text", text: time }], + structuredContent: { time }, }; }, ); diff --git a/examples/basic-server-vanillajs/src/mcp-app.ts b/examples/basic-server-vanillajs/src/mcp-app.ts index 59e71282..2a5500a6 100644 --- a/examples/basic-server-vanillajs/src/mcp-app.ts +++ b/examples/basic-server-vanillajs/src/mcp-app.ts @@ -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]"; } diff --git a/examples/budget-allocator-server/server.ts b/examples/budget-allocator-server/server.ts index 3b780d16..a902eea3 100755 --- a/examples/budget-allocator-server/server.ts +++ b/examples/budget-allocator-server/server.ts @@ -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 => { @@ -276,9 +301,10 @@ export function createServer(): McpServer { content: [ { type: "text", - text: JSON.stringify(response), + text: formatBudgetSummary(response), }, ], + structuredContent: response, }; }, ); diff --git a/examples/budget-allocator-server/src/mcp-app.ts b/examples/budget-allocator-server/src/mcp-app.ts index 4510a854..4e8c62df 100644 --- a/examples/budget-allocator-server/src/mcp-app.ts +++ b/examples/budget-allocator-server/src/mcp-app.ts @@ -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); } diff --git a/examples/cohort-heatmap-server/server.ts b/examples/cohort-heatmap-server/server.ts index ccbe408f..6a902d6f 100644 --- a/examples/cohort-heatmap-server/server.ts +++ b/examples/cohort-heatmap-server/server.ts @@ -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, }; }, ); diff --git a/examples/cohort-heatmap-server/src/mcp-app.tsx b/examples/cohort-heatmap-server/src/mcp-app.tsx index ce5c3e1f..919b6d14 100644 --- a/examples/cohort-heatmap-server/src/mcp-app.tsx +++ b/examples/cohort-heatmap-server/src/mcp-app.tsx @@ -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 { diff --git a/examples/customer-segmentation-server/server.ts b/examples/customer-segmentation-server/server.ts index 4736f61a..527855a0 100644 --- a/examples/customer-segmentation-server/server.ts +++ b/examples/customer-segmentation-server/server.ts @@ -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 => { @@ -85,6 +109,7 @@ export function createServer(): McpServer { return { content: [{ type: "text", text: JSON.stringify(data) }], + structuredContent: data, }; }, ); diff --git a/examples/integration-server/server.ts b/examples/integration-server/server.ts index d2edd23f..700f1ebb 100644 --- a/examples/integration-server/server.ts +++ b/examples/integration-server/server.ts @@ -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 => { + const time = new Date().toISOString(); return { content: [ { type: "text", - text: JSON.stringify({ time: new Date().toISOString() }), + text: JSON.stringify({ time }), }, ], + structuredContent: { time }, }; }, ); diff --git a/examples/integration-server/src/mcp-app.tsx b/examples/integration-server/src/mcp-app.tsx index fadffdbb..7304ca9c 100644 --- a/examples/integration-server/src/mcp-app.tsx +++ b/examples/integration-server/src/mcp-app.tsx @@ -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; } diff --git a/examples/scenario-modeler-server/server.ts b/examples/scenario-modeler-server/server.ts index 3ce2066f..6abb3523 100644 --- a/examples/scenario-modeler-server/server.ts +++ b/examples/scenario-modeler-server/server.ts @@ -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; type MonthlyProjection = z.infer; @@ -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, + }, }; }, ); diff --git a/examples/scenario-modeler-server/src/mcp-app.tsx b/examples/scenario-modeler-server/src/mcp-app.tsx index e436e5a8..886e8157 100644 --- a/examples/scenario-modeler-server/src/mcp-app.tsx +++ b/examples/scenario-modeler-server/src/mcp-app.tsx @@ -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); }; diff --git a/examples/system-monitor-server/server.ts b/examples/system-monitor-server/server.ts index 4cf58882..477a9aef 100644 --- a/examples/system-monitor-server/server.ts +++ b/examples/system-monitor-server/server.ts @@ -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 => { @@ -151,6 +152,7 @@ export function createServer(): McpServer { return { content: [{ type: "text", text: JSON.stringify(stats) }], + structuredContent: stats, }; }, ); diff --git a/examples/system-monitor-server/src/mcp-app.ts b/examples/system-monitor-server/src/mcp-app.ts index 39730734..fceb73c0 100644 --- a/examples/system-monitor-server/src/mcp-app.ts +++ b/examples/system-monitor-server/src/mcp-app.ts @@ -265,13 +265,7 @@ async function fetchStats(): Promise { 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) { diff --git a/examples/threejs-server/server.ts b/examples/threejs-server/server.ts index e2f28c7d..8181bad6 100644 --- a/examples/threejs-server/server.ts +++ b/examples/threejs-server/server.ts @@ -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, }; }, ); diff --git a/examples/video-resource-server/server.ts b/examples/video-resource-server/server.ts index 70effcad..0d3a33cf 100644 --- a/examples/video-resource-server/server.ts +++ b/examples/video-resource-server/server.ts @@ -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 => { 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, }; }, ); diff --git a/examples/video-resource-server/src/mcp-app.ts b/examples/video-resource-server/src/mcp-app.ts index f530ce7f..693fe425 100644 --- a/examples/video-resource-server/src/mcp-app.ts +++ b/examples/video-resource-server/src/mcp-app.ts @@ -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 diff --git a/examples/wiki-explorer-server/server.ts b/examples/wiki-explorer-server/server.ts index 063cacfe..69002c1c 100644 --- a/examples/wiki-explorer-server/server.ts +++ b/examples/wiki-explorer-server/server.ts @@ -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 => { @@ -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, + }; } }, ); diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 6bd5c268..06ab3914 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -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; - - // 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"); + 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); } - - updateGraph(); - } catch (e) { - console.error("Failed to parse tool result:", e); + setNodeState(page.url, "expanded"); } + + updateGraph(); } app.onerror = (err) => { diff --git a/package-lock.json b/package-lock.json index 115e4154..54d049fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1632,9 +1632,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" @@ -3351,9 +3351,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": {