Skip to content

Commit e18d51b

Browse files
committed
feat(map-server): Enable registerTool with navigate-to and get-current-view
- Uncomment and fix the navigate-to tool for animated navigation - Add get-current-view tool to query camera position and bounding box - Add flyToBoundingBox function for smooth camera animation - Add setLabel function for displaying location labels
1 parent 4b23e1e commit e18d51b

File tree

1 file changed

+175
-51
lines changed

1 file changed

+175
-51
lines changed

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

Lines changed: 175 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* a navigate-to tool for the host to control navigation.
77
*/
88
import { App } from "@modelcontextprotocol/ext-apps";
9+
import { z } from "zod";
910

1011
// TypeScript declaration for Cesium loaded from CDN
1112
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -654,6 +655,76 @@ function setViewToBoundingBox(cesiumViewer: any, bbox: BoundingBox): void {
654655
);
655656
}
656657

658+
/**
659+
* Fly camera to view a bounding box with animation
660+
*/
661+
function flyToBoundingBox(
662+
cesiumViewer: any,
663+
bbox: BoundingBox,
664+
duration = 2,
665+
): Promise<void> {
666+
return new Promise((resolve) => {
667+
const { destination, centerLon, centerLat, height } =
668+
calculateDestination(bbox);
669+
670+
log.info("flyTo destination:", centerLon, centerLat, "height:", height);
671+
672+
cesiumViewer.camera.flyTo({
673+
destination,
674+
orientation: {
675+
heading: 0,
676+
pitch: Cesium.Math.toRadians(-90), // Look straight down
677+
roll: 0,
678+
},
679+
duration,
680+
complete: () => {
681+
log.info(
682+
"flyTo complete, camera height:",
683+
cesiumViewer.camera.positionCartographic.height,
684+
);
685+
resolve();
686+
},
687+
cancel: () => {
688+
log.warn("flyTo cancelled");
689+
resolve();
690+
},
691+
});
692+
});
693+
}
694+
695+
// Label element for displaying location info
696+
let labelElement: HTMLDivElement | null = null;
697+
698+
/**
699+
* Set or clear the label displayed on the map
700+
*/
701+
function setLabel(text?: string): void {
702+
if (!labelElement) {
703+
labelElement = document.createElement("div");
704+
labelElement.style.cssText = `
705+
position: absolute;
706+
top: 10px;
707+
left: 10px;
708+
background: rgba(0, 0, 0, 0.7);
709+
color: white;
710+
padding: 8px 12px;
711+
border-radius: 4px;
712+
font-family: sans-serif;
713+
font-size: 14px;
714+
z-index: 100;
715+
pointer-events: none;
716+
`;
717+
document.body.appendChild(labelElement);
718+
}
719+
720+
if (text) {
721+
labelElement.textContent = text;
722+
labelElement.style.display = "block";
723+
} else {
724+
labelElement.style.display = "none";
725+
}
726+
}
727+
657728
/**
658729
* Wait for globe tiles to finish loading
659730
*/
@@ -886,57 +957,110 @@ app.ontoolinput = async (params) => {
886957
}
887958
};
888959

889-
/*
890-
Register tools for the model to interact w/ this component
891-
Needs https://github.com/modelcontextprotocol/ext-apps/pull/72
892-
*/
893-
// app.registerTool(
894-
// "navigate-to",
895-
// {
896-
// title: "Navigate To",
897-
// description: "Navigate the globe to a new bounding box location",
898-
// inputSchema: z.object({
899-
// west: z.number().describe("Western longitude (-180 to 180)"),
900-
// south: z.number().describe("Southern latitude (-90 to 90)"),
901-
// east: z.number().describe("Eastern longitude (-180 to 180)"),
902-
// north: z.number().describe("Northern latitude (-90 to 90)"),
903-
// duration: z
904-
// .number()
905-
// .optional()
906-
// .describe("Animation duration in seconds (default: 2)"),
907-
// label: z.string().optional().describe("Optional label to display"),
908-
// }),
909-
// },
910-
// async (args) => {
911-
// if (!viewer) {
912-
// return {
913-
// content: [
914-
// { type: "text" as const, text: "Error: Viewer not initialized" },
915-
// ],
916-
// isError: true,
917-
// };
918-
// }
919-
920-
// const bbox: BoundingBox = {
921-
// west: args.west,
922-
// south: args.south,
923-
// east: args.east,
924-
// north: args.north,
925-
// };
926-
927-
// await flyToBoundingBox(viewer, bbox, args.duration ?? 2);
928-
// setLabel(args.label);
929-
930-
// return {
931-
// content: [
932-
// {
933-
// type: "text" as const,
934-
// text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`,
935-
// },
936-
// ],
937-
// };
938-
// },
939-
// );
960+
// Register tools for the model to interact with this component
961+
app.registerTool(
962+
"navigate-to",
963+
{
964+
title: "Navigate To",
965+
description: "Navigate the globe to a new bounding box location",
966+
inputSchema: z.object({
967+
west: z.number().describe("Western longitude (-180 to 180)"),
968+
south: z.number().describe("Southern latitude (-90 to 90)"),
969+
east: z.number().describe("Eastern longitude (-180 to 180)"),
970+
north: z.number().describe("Northern latitude (-90 to 90)"),
971+
duration: z
972+
.number()
973+
.optional()
974+
.describe("Animation duration in seconds (default: 2)"),
975+
label: z.string().optional().describe("Optional label to display"),
976+
}),
977+
},
978+
async (args) => {
979+
if (!viewer) {
980+
return {
981+
content: [
982+
{ type: "text" as const, text: "Error: Viewer not initialized" },
983+
],
984+
isError: true,
985+
};
986+
}
987+
988+
const bbox: BoundingBox = {
989+
west: args.west,
990+
south: args.south,
991+
east: args.east,
992+
north: args.north,
993+
};
994+
995+
await flyToBoundingBox(viewer, bbox, args.duration ?? 2);
996+
setLabel(args.label);
997+
998+
return {
999+
content: [
1000+
{
1001+
type: "text" as const,
1002+
text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`,
1003+
},
1004+
],
1005+
};
1006+
},
1007+
);
1008+
1009+
app.registerTool(
1010+
"get-current-view",
1011+
{
1012+
title: "Get Current View",
1013+
description:
1014+
"Get the current camera position and bounding box visible on the globe",
1015+
},
1016+
async () => {
1017+
if (!viewer) {
1018+
return {
1019+
content: [
1020+
{ type: "text" as const, text: "Error: Viewer not initialized" },
1021+
],
1022+
isError: true,
1023+
};
1024+
}
1025+
1026+
const camera = viewer.camera;
1027+
const positionCartographic = camera.positionCartographic;
1028+
const latitude = Cesium.Math.toDegrees(positionCartographic.latitude);
1029+
const longitude = Cesium.Math.toDegrees(positionCartographic.longitude);
1030+
const height = positionCartographic.height;
1031+
1032+
// Get the visible bounding box
1033+
const rectangle = viewer.camera.computeViewRectangle();
1034+
let bbox = null;
1035+
if (rectangle) {
1036+
bbox = {
1037+
west: Cesium.Math.toDegrees(rectangle.west),
1038+
south: Cesium.Math.toDegrees(rectangle.south),
1039+
east: Cesium.Math.toDegrees(rectangle.east),
1040+
north: Cesium.Math.toDegrees(rectangle.north),
1041+
};
1042+
}
1043+
1044+
const viewData = {
1045+
camera: {
1046+
latitude,
1047+
longitude,
1048+
height,
1049+
},
1050+
bbox,
1051+
};
1052+
1053+
return {
1054+
content: [
1055+
{
1056+
type: "text" as const,
1057+
text: JSON.stringify(viewData, null, 2),
1058+
},
1059+
],
1060+
structuredContent: viewData,
1061+
};
1062+
},
1063+
);
9401064

9411065
// Handle tool result - extract widgetUUID and restore persisted view if available
9421066
app.ontoolresult = async (result) => {

0 commit comments

Comments
 (0)