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

View file

@ -224,6 +224,30 @@ function generateHistory(
return months; 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 // MCP Server Setup
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -248,6 +272,7 @@ export function createServer(): McpServer {
description: 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",
inputSchema: {}, inputSchema: {},
outputSchema: BudgetDataResponseSchema,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri }, _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
@ -276,9 +301,10 @@ export function createServer(): McpServer {
content: [ content: [
{ {
type: "text", 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) => { app.ontoolresult = (result) => {
log.info("Received tool result:", result); log.info("Received tool result:", result);
const text = result const data = result.structuredContent as unknown as BudgetDataResponse;
.content!.filter(
(c): c is { type: "text"; text: string } => c.type === "text",
)
.map((c) => c.text)
.join("");
const data = JSON.parse(text) as BudgetDataResponse;
if (data?.config && data?.analytics) { if (data?.config && data?.analytics) {
initializeUI(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 { export function createServer(): McpServer {
const server = new McpServer({ const server = new McpServer({
name: "Cohort Heatmap Server", name: "Cohort Heatmap Server",
@ -169,6 +180,7 @@ export function createServer(): McpServer {
description: description:
"Returns cohort retention heatmap data showing customer retention over time by signup month", "Returns cohort retention heatmap data showing customer retention over time by signup month",
inputSchema: GetCohortDataInputSchema.shape, inputSchema: GetCohortDataInputSchema.shape,
outputSchema: CohortDataSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri }, _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
}, },
async ({ metric, periodType, cohortCount, maxPeriods }) => { async ({ metric, periodType, cohortCount, maxPeriods }) => {
@ -180,7 +192,8 @@ export function createServer(): McpServer {
); );
return { 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, maxPeriods: 12,
}, },
}); });
const text = result setData(result.structuredContent as unknown as CohortData);
.content!.filter(
(c): c is { type: "text"; text: string } => c.type === "text",
)
.map((c) => c.text)
.join("");
setData(JSON.parse(text) as CohortData);
} catch (e) { } catch (e) {
console.error("Failed to fetch cohort data:", e); console.error("Failed to fetch cohort data:", e);
} finally { } finally {

View file

@ -30,6 +30,29 @@ const GetCustomerDataInputSchema = z.object({
.describe("Filter by segment (default: All)"), .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 // Cache generated data for session consistency
let cachedCustomers: Customer[] | null = null; let cachedCustomers: Customer[] | null = null;
let cachedSegments: SegmentSummary[] | null = null; let cachedSegments: SegmentSummary[] | null = null;
@ -78,6 +101,7 @@ export function createServer(): McpServer {
description: description:
"Returns customer data with segment information for visualization. Optionally filter by segment.", "Returns customer data with segment information for visualization. Optionally filter by segment.",
inputSchema: GetCustomerDataInputSchema.shape, inputSchema: GetCustomerDataInputSchema.shape,
outputSchema: GetCustomerDataOutputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri }, _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
}, },
async ({ segment }): Promise<CallToolResult> => { async ({ segment }): Promise<CallToolResult> => {
@ -85,6 +109,7 @@ export function createServer(): McpServer {
return { return {
content: [{ type: "text", text: JSON.stringify(data) }], content: [{ type: "text", text: JSON.stringify(data) }],
structuredContent: data,
}; };
}, },
); );

View file

@ -6,6 +6,7 @@ import type {
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { z } from "zod";
import { import {
registerAppTool, registerAppTool,
registerAppResource, registerAppResource,
@ -33,16 +34,21 @@ export function createServer(): McpServer {
title: "Get Time", title: "Get Time",
description: "Returns the current server time.", description: "Returns the current server time.",
inputSchema: {}, inputSchema: {},
outputSchema: z.object({
time: z.string(),
}),
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI }, _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
}, },
async (): Promise<CallToolResult> => { async (): Promise<CallToolResult> => {
const time = new Date().toISOString();
return { return {
content: [ content: [
{ {
type: "text", 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 { function extractTime(callToolResult: CallToolResult): string {
const text = callToolResult const { time } = callToolResult.structuredContent as { time: string };
.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 };
return time; 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 // Types derived from schemas
type ScenarioInputs = z.infer<typeof ScenarioInputsSchema>; type ScenarioInputs = z.infer<typeof ScenarioInputsSchema>;
type MonthlyProjection = z.infer<typeof MonthlyProjectionSchema>; type MonthlyProjection = z.infer<typeof MonthlyProjectionSchema>;
@ -243,6 +250,37 @@ const DEFAULT_INPUTS: ScenarioInputs = {
fixedCosts: 30000, 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 // MCP Server
// ============================================================================ // ============================================================================
@ -269,6 +307,7 @@ export function createServer(): McpServer {
description: description:
"Returns SaaS scenario templates and optionally computes custom projections for given inputs", "Returns SaaS scenario templates and optionally computes custom projections for given inputs",
inputSchema: GetScenarioDataInputSchema.shape, inputSchema: GetScenarioDataInputSchema.shape,
outputSchema: GetScenarioDataOutputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri }, _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
}, },
async (args: { async (args: {
@ -278,18 +317,28 @@ export function createServer(): McpServer {
? calculateScenario(args.customInputs) ? calculateScenario(args.customInputs)
: undefined; : 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 { return {
content: [ content: [{ type: "text", text }],
{ structuredContent: {
type: "text", templates: SCENARIO_TEMPLATES,
text: JSON.stringify({ defaultInputs: DEFAULT_INPUTS,
templates: SCENARIO_TEMPLATES, customProjections: customScenario?.projections,
defaultInputs: DEFAULT_INPUTS, customSummary: customScenario?.summary,
customProjections: customScenario?.projections, },
customSummary: customScenario?.summary,
}),
},
],
}; };
}, },
); );

View file

@ -1,6 +1,5 @@
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react"; import { useApp } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState, useMemo, useCallback, useEffect } from "react"; import { StrictMode, useState, useMemo, useCallback, useEffect } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { SliderRow } from "./components/SliderRow.tsx"; import { SliderRow } from "./components/SliderRow.tsx";
@ -25,18 +24,6 @@ interface CallToolResultData {
defaultInputs?: ScenarioInputs; 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" }; const APP_INFO = { name: "SaaS Scenario Modeler", version: "1.0.0" };
function getSafeAreaPaddingStyle(hostContext?: McpUiHostContext) { function getSafeAreaPaddingStyle(hostContext?: McpUiHostContext) {
@ -70,7 +57,8 @@ function ScenarioModeler() {
capabilities: {}, capabilities: {},
onAppCreated: (app) => { onAppCreated: (app) => {
app.ontoolresult = async (result) => { app.ontoolresult = async (result) => {
const { templates, defaultInputs } = extractResultData(result); const { templates, defaultInputs } =
result.structuredContent as unknown as CallToolResultData;
if (templates) setTemplates(templates); if (templates) setTemplates(templates);
if (defaultInputs) setDefaultInputs(defaultInputs); if (defaultInputs) setDefaultInputs(defaultInputs);
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -95,6 +95,19 @@ export function createServer(): McpServer {
.default("https://en.wikipedia.org/wiki/Model_Context_Protocol") .default("https://en.wikipedia.org/wiki/Model_Context_Protocol")
.describe("Wikipedia page URL"), .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 }, _meta: { [RESOURCE_URI_META_KEY]: resourceUri },
}, },
async ({ url }): Promise<CallToolResult> => { async ({ url }): Promise<CallToolResult> => {
@ -121,11 +134,17 @@ export function createServer(): McpServer {
const links = extractWikiLinks(new URL(url), html); const links = extractWikiLinks(new URL(url), html);
const result = { page: { url, title }, links, error: null }; 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) { } catch (err) {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
const result = { page: { url, title }, links: [], error }; 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 { function handleToolResultData(result: CallToolResult): void {
if ( if (result.isError) {
result.isError ||
!result.content?.[0] ||
result.content[0].type !== "text"
) {
console.error("Tool result error:", result); console.error("Tool result error:", result);
return; return;
} }
try { const response = result.structuredContent as unknown as ToolResponse;
const response: ToolResponse = JSON.parse(result.content[0].text); const { page, links, error } = response;
const { page, links, error } = response;
// Ensure the source node exists // Ensure the source node exists
addNode(page.url, page.title); addNode(page.url, page.title);
updateNodeTitle(page.url, page.title); updateNodeTitle(page.url, page.title);
if (error) { if (error) {
setNodeState(page.url, "error", error); setNodeState(page.url, "error", error);
} else { } else {
// Get source node position so new nodes appear nearby // Get source node position so new nodes appear nearby
const sourceNode = graphData.nodes.find((n) => n.url === page.url); const sourceNode = graphData.nodes.find((n) => n.url === page.url);
const sourcePos = sourceNode const sourcePos = sourceNode
? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 } ? { x: sourceNode.x ?? 0, y: sourceNode.y ?? 0 }
: undefined; : undefined;
// Add all linked nodes and edges // Add all linked nodes and edges
for (const link of links) { for (const link of links) {
addNode(link.url, link.title, "default", sourcePos); addNode(link.url, link.title, "default", sourcePos);
addEdge(page.url, link.url); addEdge(page.url, link.url);
}
setNodeState(page.url, "expanded");
} }
setNodeState(page.url, "expanded");
updateGraph();
} catch (e) {
console.error("Failed to parse tool result:", e);
} }
updateGraph();
} }
app.onerror = (err) => { app.onerror = (err) => {

12
package-lock.json generated
View file

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