import TMDB from "@blacktiger/tmdb";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getJson } from "serpapi";
import { z } from "zod";
// Type for ASSETS binding (Fetcher from @cloudflare/workers-types)
type AssetsBinding = {
fetch: (request: Request | string) => Promise<Response>;
};
// Widget configuration type
type WidgetConfig = {
name: string;
htmlPath: string;
resourceUri: string;
description: string;
cspDomains?: string[];
};
type SerpShowtimeResponse = {
showtimes?: Array<{
day?: string;
date?: string;
theaters?: Array<{
name?: string;
link?: string;
distance?: string;
address?: string;
showing?: Array<{
time?: string[];
type?: string;
}>;
}>;
}>;
};
async function loadHtml(
assets: AssetsBinding | undefined,
htmlPath: string,
): Promise<string> {
try {
if (!assets) {
throw new Error("ASSETS binding not available");
}
const buildRequest = (path: string) =>
// Assets fetcher expects an absolute URL, so use a placeholder origin.
new Request(new URL(path, "https://assets.invalid").toString());
// Fetch HTML file from the ASSETS binding
const htmlResponse = await assets.fetch(buildRequest(htmlPath));
if (!htmlResponse.ok) {
throw new Error(
`Failed to fetch HTML: ${htmlResponse.status}`,
);
}
return await htmlResponse.text();
} catch (error) {
console.error("Failed to load HTML:", error);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Error</title>
</head>
<body>
<div>Error loading widget HTML</div>
</body>
</html>`;
}
}
function registerWidget(
server: McpServer,
assets: AssetsBinding | undefined,
config: WidgetConfig,
): void {
server.registerResource(
config.name,
config.resourceUri,
{
description: config.description,
mimeType: "text/html+mcp",
_meta: {
ui: {
csp: {
resourceDomains: config.cspDomains ?? ["https://image.tmdb.org/"],
},
},
},
},
async () => {
// Load HTML file dynamically using ASSETS binding
// The HTML file already contains all inlined CSS and JS from vite-plugin-singlefile
const html = await loadHtml(assets, config.htmlPath);
return {
contents: [
{
uri: config.resourceUri,
mimeType: "text/html+mcp",
text: html,
_meta: {
ui: {
csp: {
resourceDomains: config.cspDomains ?? [
"https://image.tmdb.org/",
],
},
},
},
},
],
};
},
);
}
function normalizeShowtimes(response: SerpShowtimeResponse | undefined): Array<{
day?: string;
date?: string;
theaters: Array<{
name: string;
link?: string;
distance?: string;
address?: string;
showings: Array<{ type?: string; times: string[] }>;
}>;
}> {
const showtimes = response?.showtimes;
if (!Array.isArray(showtimes)) return [];
return showtimes.slice(0, 4).map((entry) => {
const theatersRaw = Array.isArray(entry?.theaters)
? (entry?.theaters ?? []).slice(0, 3)
: [];
const theaters = theatersRaw
.map((theater) => {
if (!theater) return null;
const distance =
typeof theater.distance === "number"
? `${theater.distance}`
: (theater.distance ?? undefined);
const address =
typeof theater.address === "string" ? theater.address : undefined;
const showings = Array.isArray(theater.showing)
? theater.showing
.map((showing) => {
if (!showing) return null;
const times = Array.isArray(showing.time)
? showing.time.filter(
(time): time is string => typeof time === "string",
)
: [];
return { type: showing.type, times };
})
.filter(Boolean)
: [];
return {
name: theater.name ?? "Unknown theater",
link: theater.link ?? undefined,
distance,
address,
showings,
};
})
.filter(Boolean);
return {
day: entry?.day,
date: entry?.date,
theaters,
};
});
}
async function fetchPosterUrl(
title: string,
tmdbKey: string | undefined,
): Promise<string | undefined> {
if (!tmdbKey) return undefined;
try {
const tmdb = new TMDB(tmdbKey, "en-US");
const searchResponse = await tmdb.search.movie(title, {
includeAdult: false,
page: 1,
});
const firstMatch = searchResponse.results?.[0];
if (!firstMatch?.poster_path) return undefined;
return `https://image.tmdb.org/t/p/w500${firstMatch.poster_path}`;
} catch (error) {
console.error("Failed to fetch poster from TMDB:", error);
return undefined;
}
}
export function createMcpServer(
assets?: AssetsBinding,
tmdbToken?: string,
): McpServer {
const server = new McpServer({
name: "usher-mcp",
version: "0.1.0",
});
// Register movie-detail-widget
registerWidget(server, assets, {
name: "movie-detail-widget",
htmlPath: "/movie-detail-widget.html",
resourceUri: "ui://widget/movie-detail-widget.html",
description: "Interactive movie detail widget UI",
cspDomains: ["https://image.tmdb.org/"],
});
// Register movie-showtime-widget
registerWidget(server, assets, {
name: "movie-showtime-widget",
htmlPath: "/movie-showtime-widget.html",
resourceUri: "ui://widget/movie-showtime-widget.html",
description: "Interactive movie showtime widget UI",
cspDomains: ["https://image.tmdb.org/"],
});
server.registerTool(
"get-movie-detail",
{
description:
"Search TMDB by title and return the best matching movie details",
inputSchema: z.object({
query: z
.string()
.min(1, "Please provide a movie title")
.describe("Movie title to search for"),
}),
_meta: {
"ui/resourceUri": "ui://widget/movie-detail-widget.html",
},
},
async ({ query }) => {
const apiKey = tmdbToken ?? process.env.TMDB_TOKEN;
if (!apiKey) {
throw new Error(
"TMDB_TOKEN is not set. Please add it to your environment.",
);
}
const tmdb = new TMDB(apiKey, "en-US");
const searchResponse = await tmdb.search.movie(query, {
includeAdult: false,
page: 1,
});
const firstMatch = searchResponse.results?.[0];
if (!firstMatch) {
return {
content: [
{
type: "text",
text: `No results found for "${query}".`,
},
],
structuredContent: {
query,
movie: null,
},
};
}
const [details, credits] = await Promise.all([
tmdb.movie.details(firstMatch.id),
tmdb.movie.credits(firstMatch.id).catch(() => undefined),
]);
const cast =
credits?.cast
?.filter((member) => Boolean(member?.name))
.slice(0, 8)
.map((member) => member.name) ?? [];
const moviePayload = {
id: details.id,
title: details.title ?? details.original_title,
releaseDate: details.release_date,
overview: details.overview,
runtimeMinutes: details.runtime,
rating: details.vote_average,
genres:
details.genres?.map((genre) => genre.name).filter(Boolean) ?? [],
language:
details.spoken_languages?.[0]?.english_name ??
details.original_language,
tagline: details.tagline,
studio: details.production_companies?.[0]?.name,
posterUrl: details.poster_path
? `https://image.tmdb.org/t/p/w500${details.poster_path}`
: undefined,
backdropUrl: details.backdrop_path
? `https://image.tmdb.org/t/p/w1280${details.backdrop_path}`
: undefined,
homepage: details.homepage,
cast,
query,
};
return {
content: [
{
type: "text",
text: `Showing results for "${query}": ${moviePayload.title ?? "Unknown title"}.`,
},
],
structuredContent: {
query,
movie: moviePayload,
},
};
},
);
server.registerTool(
"get-movie-showtime",
{
description: "Search for movie showtimes by title and location",
inputSchema: z.object({
movieTitle: z
.string()
.min(1, "Please provide a movie title")
.describe("Movie title to search showtimes for"),
location: z
.string()
.min(1, "Please provide a location")
.describe("Location to search showtimes in"),
}),
_meta: {
"ui/resourceUri": "ui://widget/movie-showtime-widget.html",
},
},
async ({ movieTitle, location }) => {
const serpApiKey = process.env.SERP_TOKEN;
if (!serpApiKey) {
throw new Error(
"SERP_API_KEY is not set. Please add it to your environment.",
);
}
const query = `${movieTitle} ${location} theater`;
let serpResponse: SerpShowtimeResponse | undefined;
try {
serpResponse = (await getJson({
q: query,
location,
hl: "en",
gl: "us",
api_key: serpApiKey,
})) as SerpShowtimeResponse;
} catch (error) {
console.error("Failed to fetch showtimes from SerpAPI:", error);
throw new Error(
"Unable to fetch showtimes right now. Please try again.",
);
}
const showtimes = normalizeShowtimes(serpResponse);
const posterUrl = await fetchPosterUrl(
movieTitle,
tmdbToken ?? process.env.TMDB_TOKEN,
);
if (showtimes.length === 0) {
return {
content: [
{
type: "text",
text: `No showtimes found for "${movieTitle}" in "${location}".`,
},
],
structuredContent: {
movieTitle,
location,
query,
posterUrl,
showtimes,
},
};
}
return {
content: [
{
type: "text",
text: `Showtimes for "${movieTitle}" in "${location}".`,
},
],
structuredContent: {
movieTitle,
location,
query,
posterUrl,
showtimes,
},
};
},
);
return server;
}