import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { MENU_WIDGET_BUNDLE } from "./generated/menuBundle";
import { generateMenuItems } from "./menu/generator.js";
if (!MENU_WIDGET_BUNDLE || MENU_WIDGET_BUNDLE.length === 0) {
throw new Error(
'Menu widget bundle missing. Run "npm run build" before building the server.'
);
}
export const configSchema = z.object({
defaultTitle: z
.string()
.min(1)
.describe("Fallback menu title shown when a tool call does not supply a custom title.")
.default("What's next?")
});
export type ServerConfig = z.infer<typeof configSchema>;
const MenuItem = z.object({
id: z
.string()
.min(1)
.max(40)
.describe("Stable identifier for the menu item, echoed back through do_action."),
label: z
.string()
.min(1)
.max(60)
.describe("Primary label shown to the user inside the menu."),
hint: z
.string()
.max(120)
.describe("Optional supporting copy that clarifies what happens on selection.")
.optional(),
icon: z
.string()
.max(64)
.describe("Optional short icon token (emoji, initial, or glyph id) for visual affordance.")
.optional()
});
const MenuPayload = z.object({
title: z.string().min(1).describe("Visible menu heading conveying the current decision context."),
items: z.array(MenuItem).describe("Ordered list of actionable next steps for the user to choose from.")
});
const GetMenuInput = z.object({
proposedItems: z
.array(MenuItem)
.describe("Optional list of menu items supplied by the caller. When absent, defaults will be generated.")
.optional(),
title: z
.string()
.describe("Optional custom title to override the configured fallback for this specific turn.")
.optional(),
locale: z
.enum(["uk", "ru", "en"])
.describe("BCP-47 locale driving any default copy or heuristics.")
.default("en"),
context: z
.string()
.describe("Optional free-form context about the surrounding conversation turn.")
.optional()
});
const DoActionInput = z.object({
id: z.string().min(1).describe("Identifier of the menu item the user selected."),
label: z
.string()
.describe("Label of the selection when available (mirrors MenuItem.label).")
.optional()
});
const DoActionOutput = z.object({
done: z.boolean().describe("Signals that the action completed synchronously."),
message: z.string().describe("Summary of what happened after the action executed."),
menu: MenuPayload.describe("Optional refreshed menu so the user can continue the flow.").optional()
});
export function createUniversalMenuServer({ config }: { config: ServerConfig }) {
const server = new McpServer({
name: "appsdk-universal-menu",
version: "1.0.0",
title: "Context Menu",
description:
"Always call get_menu after every assistant message to render context-aware follow-up choices, and call do_action whenever the user selects an item to confirm the action and refresh the menu."
});
server.registerResource(
"menu-widget",
"ui://widget/menu.html",
{},
async () => ({
contents: [
{
uri: "ui://widget/menu.html",
mimeType: "text/html+skybridge",
text: `
<div id="menu-root"></div>
<script type="module">${MENU_WIDGET_BUNDLE}</script>`.trim(),
_meta: {
"openai/widgetDescription": "Interactive next-step menu rendered after each reply.",
"openai/widgetPrefersBorder": true,
"openai/widgetCSP": { connect_domains: [], resource_domains: [] }
}
}
]
})
);
server.registerTool(
"get_menu",
{
title: "Build next-step menu",
description:
"Use this when crafting the assistant reply to surface contextual next steps. Always call after every assistant message so the user can choose how to continue. Do not call after the user has already picked an item; use do_action instead.",
inputSchema: GetMenuInput.shape,
outputSchema: MenuPayload.shape,
annotations: {
readOnlyHint: true
},
_meta: {
"openai/outputTemplate": "ui://widget/menu.html",
"openai/widgetAccessible": true,
"openai/toolInvocation/invoking": "Preparing menu...",
"openai/toolInvocation/invoked": "Menu ready"
}
},
async ({ proposedItems, title, locale, context }) => {
const items =
proposedItems && proposedItems.length > 0
? proposedItems
: await generateMenuItems({
locale,
context
});
const payload = {
title: title ?? config.defaultTitle,
items
};
return {
structuredContent: payload,
content: [{ type: "text", text: "Done. Here are the next actions." }]
};
}
);
server.registerTool(
"do_action",
{
title: "Execute menu action",
description:
"Use this when the user selects an item from the menu to confirm the action, run any follow-up logic, and refresh the menu. Do not call unless the user has explicitly chosen an item.",
inputSchema: DoActionInput.shape,
outputSchema: DoActionOutput.shape,
_meta: {
"openai/outputTemplate": "ui://widget/menu.html",
"openai/widgetAccessible": true,
"openai/toolInvocation/invoking": "Running action...",
"openai/toolInvocation/invoked": "Completed"
}
},
async ({ id, label }) => {
const message = `Action completed: ${label ?? id}`;
const nextMenu = {
title: "All set ✅",
items: [
{ id: "ask_more", label: "Ask another question" },
{ id: "summarize", label: "Provide a short summary" }
]
};
const payload = {
done: true,
message,
menu: nextMenu
} satisfies z.infer<typeof DoActionOutput>;
return {
...payload,
structuredContent: payload,
content: [{ type: "text" as const, text: message }]
};
}
);
return server;
}
export default function createServer(args: { config: ServerConfig }) {
return createUniversalMenuServer(args).server;
}