Skip to content

Commit 75ecd62

Browse files
authored
fix(examples): improve updateModelContext with structured YAML frontmatter (#271)
* map-server: fix async/await in ontoolresult, remove unused getViewStorageKey * fix(map-server): improve updateModelContext and fix initialization - Use structured YAML frontmatter format for model context (like pdf-server) - Add tool name, altitude, and cleaner data format - Fix initialization deadlock: app.connect() was inside ontoolresult handler which could never fire until connected - Fix view state persistence: widgetUUID now properly extracted from ontoolresult and used to restore persisted camera position - Server now includes widgetUUID in tool result _meta for widget identification * fix(pdf-server): use widgetUUID for page persistence - Add widgetUUID to tool result _meta for widget identification - Use widgetUUID instead of generated key (pdfUrl+toolId) for localStorage - Enables reliable page restoration when revisiting PDF widgets * fix(transcript-server): use structured YAML frontmatter for model context - Add tool name, listening status, and unsent entry count in frontmatter - Consistent format with pdf-server and map-server * update names * drop bogus altitude * Update mcp-app.ts
1 parent db1b485 commit 75ecd62

7 files changed

Lines changed: 122 additions & 99 deletions

File tree

examples/map-server/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
RESOURCE_URI_META_KEY,
2222
} from "@modelcontextprotocol/ext-apps/server";
2323
import { startServer } from "./server-utils.js";
24+
import { randomUUID } from "crypto";
2425

2526
const DIST_DIR = path.join(import.meta.dirname, "dist");
2627
const RESOURCE_URI = "ui://cesium-map/mcp-app.html";
@@ -182,6 +183,9 @@ export function createServer(): McpServer {
182183
text: `Displaying globe at: W:${west.toFixed(4)}, S:${south.toFixed(4)}, E:${east.toFixed(4)}, N:${north.toFixed(4)}${label ? ` (${label})` : ""}`,
183184
},
184185
],
186+
_meta: {
187+
widgetUUID: randomUUID(),
188+
},
185189
}),
186190
);
187191

examples/map-server/src/mcp-app.ts

Lines changed: 86 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ let persistViewTimer: ReturnType<typeof setTimeout> | null = null;
7171
// Track whether tool input has been received (to know if we should restore persisted state)
7272
let hasReceivedToolInput = false;
7373

74+
let widgetUUID: string | undefined = undefined;
75+
7476
/**
7577
* Persisted camera state for localStorage
7678
*/
@@ -83,18 +85,6 @@ interface PersistedCameraState {
8385
roll: number; // radians
8486
}
8587

86-
/**
87-
* Get localStorage key for persisting view state
88-
* Uses toolInfo.id (tool invocation ID) - localStorage is scoped per conversation per server,
89-
* so each tool call remembers its own view state within the conversation.
90-
*/
91-
function getViewStorageKey(): string | null {
92-
const context = app.getHostContext();
93-
const toolId = context?.toolInfo?.id;
94-
if (!toolId) return null;
95-
return `cesium-view:${toolId}`;
96-
}
97-
9888
/**
9989
* Get current camera state for persistence
10090
*/
@@ -132,8 +122,7 @@ function schedulePersistViewState(cesiumViewer: any): void {
132122
* Persist current view state to localStorage
133123
*/
134124
function persistViewState(cesiumViewer: any): void {
135-
const key = getViewStorageKey();
136-
if (!key) {
125+
if (!widgetUUID) {
137126
log.info("No storage key available, skipping view persistence");
138127
return;
139128
}
@@ -142,13 +131,9 @@ function persistViewState(cesiumViewer: any): void {
142131
if (!state) return;
143132

144133
try {
145-
localStorage.setItem(key, JSON.stringify(state));
146-
log.info(
147-
"Persisted view state:",
148-
key,
149-
state.latitude.toFixed(2),
150-
state.longitude.toFixed(2),
151-
);
134+
const value = JSON.stringify(state);
135+
localStorage.setItem(widgetUUID, value);
136+
log.info("Persisted view state:", widgetUUID, value);
152137
} catch (e) {
153138
log.warn("Failed to persist view state:", e);
154139
}
@@ -158,12 +143,14 @@ function persistViewState(cesiumViewer: any): void {
158143
* Load persisted view state from localStorage
159144
*/
160145
function loadPersistedViewState(): PersistedCameraState | null {
161-
const key = getViewStorageKey();
162-
if (!key) return null;
146+
if (!widgetUUID) return null;
163147

164148
try {
165-
const stored = localStorage.getItem(key);
166-
if (!stored) return null;
149+
const stored = localStorage.getItem(widgetUUID);
150+
if (!stored) {
151+
console.info("No persisted view state found");
152+
return null;
153+
}
167154

168155
const state = JSON.parse(stored) as PersistedCameraState;
169156
// Basic validation
@@ -175,6 +162,7 @@ function loadPersistedViewState(): PersistedCameraState | null {
175162
log.warn("Invalid persisted view state, ignoring");
176163
return null;
177164
}
165+
log.info("Loaded persisted view state:", state);
178166
return state;
179167
} catch (e) {
180168
log.warn("Failed to load persisted view state:", e);
@@ -402,8 +390,10 @@ async function getVisiblePlaces(extent: BoundingBox): Promise<string[]> {
402390
}
403391

404392
/**
405-
* Debounced location update using multi-point reverse geocoding
406-
* Samples multiple points in the visible extent to discover places
393+
* Debounced location update using multi-point reverse geocoding.
394+
* Samples multiple points in the visible extent to discover places.
395+
*
396+
* Updates model context with structured YAML frontmatter (similar to pdf-server).
407397
*/
408398
function scheduleLocationUpdate(cesiumViewer: any): void {
409399
if (reverseGeocodeTimer) {
@@ -420,34 +410,35 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
420410
}
421411

422412
const { widthKm, heightKm } = getScaleDimensions(extent);
423-
const extentInfo =
424-
`Extent: [${extent.west.toFixed(4)}, ${extent.south.toFixed(4)}, ` +
425-
`${extent.east.toFixed(4)}, ${extent.north.toFixed(4)}] ` +
426-
`(${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km)`;
427-
log.info(extentInfo);
413+
414+
log.info(`Extent: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`);
428415

429416
// Get places visible in the extent (samples multiple points for large areas)
430417
const places = await getVisiblePlaces(extent);
431-
const placesText =
432-
places.length > 0 ? `Visible places: ${places.join(", ")}` : "";
433-
434-
if (places.length > 0 || center) {
435-
const centerText = center
436-
? `Center: ${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}`
437-
: "";
438-
439-
const contextText = [placesText, centerText, extentInfo]
440-
.filter(Boolean)
441-
.join("\n");
442418

443-
log.info("Updating model context:", contextText);
444-
445-
// Update the model's context with the current map location.
446-
// If the host doesn't support this, the request will silently fail.
447-
app.updateModelContext({
448-
content: [{ type: "text", text: contextText }],
449-
});
450-
}
419+
// Build structured markdown with YAML frontmatter (like pdf-server)
420+
// Note: tool name isn't in the notification protocol, so we hardcode it
421+
const frontmatter = [
422+
"---",
423+
`tool: show-map`,
424+
center
425+
? `center: [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`
426+
: null,
427+
`extent: [${extent.west.toFixed(4)}, ${extent.south.toFixed(4)}, ${extent.east.toFixed(4)}, ${extent.north.toFixed(4)}]`,
428+
`extent-size: ${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km`,
429+
places.length > 0 ? `visible-places: [${places.join(", ")}]` : null,
430+
"---",
431+
]
432+
.filter(Boolean)
433+
.join("\n");
434+
435+
log.info("Updating model context:", frontmatter);
436+
437+
// Update the model's context with the current map location.
438+
// If the host doesn't support this, the request will silently fail.
439+
app.updateModelContext({
440+
content: [{ type: "text", text: frontmatter }],
441+
});
451442
}, 1500);
452443
}
453444

@@ -947,40 +938,36 @@ app.ontoolinput = async (params) => {
947938
// },
948939
// );
949940

941+
// Handle tool result - extract widgetUUID and restore persisted view if available
942+
app.ontoolresult = async (result) => {
943+
widgetUUID = result._meta?.widgetUUID
944+
? String(result._meta.widgetUUID)
945+
: undefined;
946+
log.info("Tool result received, widgetUUID:", widgetUUID);
947+
948+
// Now that we have widgetUUID, try to restore persisted view
949+
// This overrides the tool input position if a saved state exists
950+
if (viewer && widgetUUID) {
951+
const restored = restorePersistedView(viewer);
952+
if (restored) {
953+
log.info("Restored persisted view from tool result handler");
954+
await waitForTilesLoaded(viewer);
955+
hideLoading();
956+
}
957+
}
958+
};
959+
950960
// Initialize Cesium and connect to host
951-
async function init() {
961+
async function initialize() {
952962
try {
953963
log.info("Loading CesiumJS from CDN...");
954964
await loadCesium();
955965
log.info("CesiumJS loaded successfully");
956966

957967
viewer = await initCesium();
958-
// Don't hide loading here - we wait for tool input to position camera
959-
// and for tiles to load before hiding the loading indicator
960-
log.info("CesiumJS initialized, waiting for tool input...");
968+
log.info("CesiumJS initialized");
961969

962-
// Fallback: if no tool input received within 2 seconds, try restoring
963-
// persisted view or show default view
964-
setTimeout(async () => {
965-
const loadingEl = document.getElementById("loading");
966-
if (
967-
loadingEl &&
968-
loadingEl.style.display !== "none" &&
969-
!hasReceivedToolInput
970-
) {
971-
// No explicit tool input - try to restore persisted view
972-
const restored = restorePersistedView(viewer!);
973-
if (restored) {
974-
log.info("Restored persisted view, waiting for tiles...");
975-
} else {
976-
log.info("No persisted view, using default view...");
977-
}
978-
await waitForTilesLoaded(viewer!);
979-
hideLoading();
980-
}
981-
}, 2000);
982-
983-
// Connect to host (auto-creates PostMessageTransport)
970+
// Connect to host (must happen before we can receive notifications)
984971
await app.connect();
985972
log.info("Connected to host");
986973

@@ -1009,6 +996,26 @@ async function init() {
1009996

1010997
// Set up keyboard shortcuts for fullscreen (Escape to exit, Ctrl/Cmd+Enter to toggle)
1011998
document.addEventListener("keydown", handleFullscreenKeyboard);
999+
1000+
// Wait a bit for tool input, then try restoring persisted view or show default
1001+
setTimeout(async () => {
1002+
const loadingEl = document.getElementById("loading");
1003+
if (
1004+
loadingEl &&
1005+
loadingEl.style.display !== "none" &&
1006+
!hasReceivedToolInput
1007+
) {
1008+
// No explicit tool input - try to restore persisted view
1009+
const restored = restorePersistedView(viewer!);
1010+
if (restored) {
1011+
log.info("Restored persisted view, waiting for tiles...");
1012+
} else {
1013+
log.info("No persisted view, using default view...");
1014+
}
1015+
await waitForTilesLoaded(viewer!);
1016+
hideLoading();
1017+
}
1018+
}, 500);
10121019
} catch (error) {
10131020
log.error("Failed to initialize:", error);
10141021
const loadingEl = document.getElementById("loading");
@@ -1019,4 +1026,5 @@ async function init() {
10191026
}
10201027
}
10211028

1022-
init();
1029+
// Start initialization
1030+
initialize();

examples/pdf-server/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
import fs from "node:fs/promises";
2222
import path from "node:path";
2323
import { z } from "zod";
24+
import { randomUUID } from "crypto";
2425

2526
import {
2627
buildPdfIndex,
@@ -175,6 +176,9 @@ The viewer supports zoom, navigation, text selection, and fullscreen mode.`,
175176
},
176177
],
177178
structuredContent: result,
179+
_meta: {
180+
widgetUUID: randomUUID(),
181+
},
178182
};
179183
},
180184
);

examples/pdf-server/src/mcp-app.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ let totalPages = 0;
3535
let scale = 1.0;
3636
let pdfUrl = "";
3737
let pdfTitle: string | undefined;
38+
let widgetUUID: string | undefined;
3839
let currentRenderTask: { cancel: () => void } | null = null;
3940

4041
// DOM Elements
@@ -402,20 +403,11 @@ async function renderPage() {
402403
}
403404
}
404405

405-
// Page persistence
406-
function getStorageKey(): string | null {
407-
if (!pdfUrl) return null;
408-
const ctx = app.getHostContext();
409-
const toolId = ctx?.toolInfo?.id ?? pdfUrl;
410-
return `pdf:${pdfUrl}:${toolId}`;
411-
}
412-
413406
function saveCurrentPage() {
414-
const key = getStorageKey();
415-
log.info("saveCurrentPage: key=", key, "page=", currentPage);
416-
if (key) {
407+
log.info("saveCurrentPage: key=", widgetUUID, "page=", currentPage);
408+
if (widgetUUID) {
417409
try {
418-
localStorage.setItem(key, String(currentPage));
410+
localStorage.setItem(widgetUUID, String(currentPage));
419411
log.info("saveCurrentPage: saved successfully");
420412
} catch (err) {
421413
log.error("saveCurrentPage: error", err);
@@ -424,11 +416,10 @@ function saveCurrentPage() {
424416
}
425417

426418
function loadSavedPage(): number | null {
427-
const key = getStorageKey();
428-
log.info("loadSavedPage: key=", key);
429-
if (!key) return null;
419+
log.info("loadSavedPage: key=", widgetUUID);
420+
if (!widgetUUID) return null;
430421
try {
431-
const saved = localStorage.getItem(key);
422+
const saved = localStorage.getItem(widgetUUID);
432423
log.info("loadSavedPage: saved value=", saved);
433424
if (saved) {
434425
const page = parseInt(saved, 10);
@@ -715,6 +706,9 @@ app.ontoolresult = async (result) => {
715706
pdfUrl = parsed.url;
716707
pdfTitle = parsed.title;
717708
totalPages = parsed.pageCount;
709+
widgetUUID = result._meta?.widgetUUID
710+
? String(result._meta.widgetUUID)
711+
: undefined;
718712

719713
// Restore saved page or use initial page
720714
const savedPage = loadSavedPage();

examples/transcript-server/src/mcp-app.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -329,18 +329,31 @@ function updateSendButton() {
329329
sendBtn.disabled = unsentEntries.length === 0;
330330
}
331331

332+
/**
333+
* Update model context with structured YAML frontmatter (like pdf-server, map-server).
334+
*/
332335
function updateModelContext() {
333336
const caps = app.getHostCapabilities();
334337
if (!caps?.updateModelContext) return;
335338

336339
const text = getUnsentText();
340+
const unsentCount = getUnsentEntries().length;
337341
log.info("Updating model context:", text || "(empty)");
338342

343+
// Build structured markdown with YAML frontmatter
344+
const frontmatter = [
345+
"---",
346+
"tool: transcribe",
347+
`status: ${isListening ? "listening" : "paused"}`,
348+
`unsent-entries: ${unsentCount}`,
349+
"---",
350+
].join("\n");
351+
352+
const markdown = text ? `${frontmatter}\n\n${text}` : frontmatter;
353+
339354
app
340355
.updateModelContext({
341-
content: text
342-
? [{ type: "text", text: `[Live transcript]: ${text}` }]
343-
: [],
356+
content: [{ type: "text", text: markdown }],
344357
})
345358
.catch((e: unknown) => {
346359
log.warn("Failed to update model context:", e);

tests/e2e/generate-grid-screenshots.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const SERVERS = [
5252
name: "Customer Segmentation Server",
5353
dir: "customer-segmentation-server",
5454
},
55-
{ key: "map-server", name: "CesiumJS Map Server", dir: "map-server" },
55+
{ key: "map-server", name: "Map Server", dir: "map-server" },
5656
{ key: "pdf-server", name: "PDF Server", dir: "pdf-server" },
5757
{
5858
key: "scenario-modeler",

tests/e2e/servers.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const SERVERS = [
4747
{ key: "budget-allocator", name: "Budget Allocator Server" },
4848
{ key: "cohort-heatmap", name: "Cohort Heatmap Server" },
4949
{ key: "customer-segmentation", name: "Customer Segmentation Server" },
50-
{ key: "map-server", name: "CesiumJS Map Server" },
50+
{ key: "map-server", name: "Map Server" },
5151
{ key: "pdf-server", name: "PDF Server" },
5252
{ key: "scenario-modeler", name: "SaaS Scenario Modeler" },
5353
{ key: "shadertoy", name: "ShaderToy Server" },

0 commit comments

Comments
 (0)