feat: add readServerResource() and listServerResources() api to App (#470)
* feat: add readServerResource() and listServerResources() api to App class * test: add listServerResources and readServerResource integration tests
This commit is contained in:
parent
62c75203b6
commit
4f9a7ebc74
3 changed files with 502 additions and 40 deletions
|
|
@ -1,10 +1,94 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/*
|
||||
* Fallbacks for host style variables used by this app.
|
||||
* The host may provide these (and many more) via the host context.
|
||||
*/
|
||||
--color-text-primary: light-dark(#1f2937, #f3f4f6);
|
||||
--color-text-inverse: light-dark(#f3f4f6, #1f2937);
|
||||
--color-text-info: light-dark(#1d4ed8, #60a5fa);
|
||||
--color-background-primary: light-dark(#ffffff, #1a1a1a);
|
||||
--color-background-inverse: light-dark(#1a1a1a, #ffffff);
|
||||
--color-background-info: light-dark(#eff6ff, #1e3a5f);
|
||||
--color-ring-primary: light-dark(#3b82f6, #60a5fa);
|
||||
--border-radius-md: 6px;
|
||||
--border-width-regular: 1px;
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-bold: 700;
|
||||
--font-text-md-size: 1rem;
|
||||
--font-text-md-line-height: 1.5;
|
||||
--font-heading-3xl-size: 2.25rem;
|
||||
--font-heading-3xl-line-height: 1.1;
|
||||
--font-heading-2xl-size: 1.875rem;
|
||||
--font-heading-2xl-line-height: 1.2;
|
||||
--font-heading-xl-size: 1.5rem;
|
||||
--font-heading-xl-line-height: 1.25;
|
||||
--font-heading-lg-size: 1.25rem;
|
||||
--font-heading-lg-line-height: 1.3;
|
||||
--font-heading-md-size: 1rem;
|
||||
--font-heading-md-line-height: 1.4;
|
||||
--font-heading-sm-size: 0.875rem;
|
||||
--font-heading-sm-line-height: 1.4;
|
||||
|
||||
/* Spacing derived from host typography */
|
||||
--spacing-unit: var(--font-text-md-size);
|
||||
--spacing-xs: calc(var(--spacing-unit) * 0.25);
|
||||
--spacing-sm: calc(var(--spacing-unit) * 0.5);
|
||||
--spacing-md: var(--spacing-unit);
|
||||
--spacing-lg: calc(var(--spacing-unit) * 1.5);
|
||||
|
||||
/* App accent color (customize for your brand) */
|
||||
--color-accent: #2563eb;
|
||||
--color-text-on-accent: #ffffff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-text-md-size);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--font-text-md-line-height);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-heading-3xl-size);
|
||||
line-height: var(--font-heading-3xl-line-height);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--font-heading-2xl-size);
|
||||
line-height: var(--font-heading-2xl-line-height);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--font-heading-xl-size);
|
||||
line-height: var(--font-heading-xl-line-height);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--font-heading-lg-size);
|
||||
line-height: var(--font-heading-lg-line-height);
|
||||
}
|
||||
h5 {
|
||||
font-size: var(--font-heading-md-size);
|
||||
line-height: var(--font-heading-md-line-height);
|
||||
}
|
||||
h6 {
|
||||
font-size: var(--font-heading-sm-size);
|
||||
line-height: var(--font-heading-sm-line-height);
|
||||
}
|
||||
|
||||
code, pre, kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
b, strong {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
color: #6b7280;
|
||||
padding: var(--spacing-lg);
|
||||
gap: var(--spacing-md);
|
||||
color: var(--color-text-info);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border: 4px solid color-mix(in srgb, var(--color-text-primary) 20%, transparent);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
|
@ -27,13 +27,13 @@
|
|||
}
|
||||
|
||||
.error {
|
||||
padding: 1rem;
|
||||
padding: var(--spacing-md);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.error p {
|
||||
|
|
@ -43,12 +43,75 @@
|
|||
.player video {
|
||||
width: 100%;
|
||||
max-height: 480px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
margin-top: 0.5rem;
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-info);
|
||||
}
|
||||
|
||||
.picker-container {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.picker-container h3 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-heading-lg-size);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.video-picker {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
font-size: var(--font-text-md-size);
|
||||
border: var(--border-width-regular) solid color-mix(in srgb, var(--color-text-primary) 20%, transparent);
|
||||
border-radius: var(--border-radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
background-color: var(--color-background-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.video-picker option {
|
||||
background-color: var(--color-background-info);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.load-btn,
|
||||
.change-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-text-md-size);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-on-accent);
|
||||
background-color: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.load-btn:hover:not(:disabled),
|
||||
.change-btn:hover {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 85%, var(--color-background-inverse));
|
||||
}
|
||||
|
||||
.load-btn:focus-visible,
|
||||
.change-btn:focus-visible {
|
||||
outline: calc(var(--border-width-regular) * 2) solid var(--color-ring-primary);
|
||||
outline-offset: var(--border-width-regular);
|
||||
}
|
||||
|
||||
.load-btn:disabled {
|
||||
background-color: color-mix(in srgb, var(--color-text-primary) 40%, transparent);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.change-btn {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import "./global.css";
|
||||
import "./mcp-app.css";
|
||||
|
||||
|
|
@ -19,9 +18,19 @@ const loadingEl = document.getElementById("loading")!;
|
|||
const loadingTextEl = document.getElementById("loading-text")!;
|
||||
const errorEl = document.getElementById("error")!;
|
||||
const errorMessageEl = document.getElementById("error-message")!;
|
||||
const pickerContainerEl = document.getElementById("video-picker-container")!;
|
||||
const videoPickerEl = document.getElementById(
|
||||
"video-picker",
|
||||
) as HTMLSelectElement;
|
||||
const loadVideoBtn = document.getElementById(
|
||||
"load-video-btn",
|
||||
) as HTMLButtonElement;
|
||||
const playerEl = document.getElementById("player")!;
|
||||
const videoEl = document.getElementById("video") as HTMLVideoElement;
|
||||
const videoInfoEl = document.getElementById("video-info")!;
|
||||
const changeVideoBtn = document.getElementById(
|
||||
"change-video-btn",
|
||||
) as HTMLButtonElement;
|
||||
|
||||
// =============================================================================
|
||||
// UI State Helpers
|
||||
|
|
@ -41,6 +50,7 @@ function showLoading(text: string) {
|
|||
loadingTextEl.textContent = text;
|
||||
loadingEl.style.display = "flex";
|
||||
errorEl.style.display = "none";
|
||||
pickerContainerEl.style.display = "none";
|
||||
playerEl.style.display = "none";
|
||||
}
|
||||
|
||||
|
|
@ -48,15 +58,36 @@ function showError(message: string) {
|
|||
errorMessageEl.textContent = message;
|
||||
loadingEl.style.display = "none";
|
||||
errorEl.style.display = "block";
|
||||
pickerContainerEl.style.display = "none";
|
||||
playerEl.style.display = "none";
|
||||
}
|
||||
|
||||
function showPlayer(dataUri: string, info: string) {
|
||||
videoEl.src = dataUri;
|
||||
function showPicker(keepPlayer = true) {
|
||||
loadingEl.style.display = "none";
|
||||
errorEl.style.display = "none";
|
||||
pickerContainerEl.style.display = "block";
|
||||
changeVideoBtn.style.display = "none";
|
||||
if (!keepPlayer || !videoEl.src || videoEl.src === window.location.href) {
|
||||
playerEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
let currentObjectUrl: string | null = null;
|
||||
|
||||
function showPlayer(objectUrl: string, info: string) {
|
||||
// Revoke previous Object URL to free memory
|
||||
if (currentObjectUrl) {
|
||||
URL.revokeObjectURL(currentObjectUrl);
|
||||
}
|
||||
currentObjectUrl = objectUrl;
|
||||
|
||||
videoEl.src = objectUrl;
|
||||
videoInfoEl.textContent = info;
|
||||
loadingEl.style.display = "none";
|
||||
errorEl.style.display = "none";
|
||||
pickerContainerEl.style.display = "none";
|
||||
playerEl.style.display = "block";
|
||||
changeVideoBtn.style.display = "inline-block";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -65,8 +96,81 @@ function showPlayer(dataUri: string, info: string) {
|
|||
|
||||
const app = new App({ name: "Video Resource Player", version: "1.0.0" });
|
||||
|
||||
// Handle tool result - Requests a resource via resources/read and converts the
|
||||
// base64 blob to a data URI for use in the browser.
|
||||
async function fetchAndPlayVideo(uri: string, label: string) {
|
||||
console.info("Requesting resource:", uri);
|
||||
|
||||
const resourceResult = await app.readServerResource({ uri });
|
||||
|
||||
const content = resourceResult.contents[0];
|
||||
if (!content || !("blob" in content)) {
|
||||
throw new Error("Resource response did not contain blob data");
|
||||
}
|
||||
|
||||
console.info("Resource received, blob size:", content.blob.length);
|
||||
|
||||
const mimeType = content.mimeType || "video/mp4";
|
||||
const binary = Uint8Array.from(atob(content.blob), (c) => c.charCodeAt(0));
|
||||
const objectUrl = URL.createObjectURL(new Blob([binary], { type: mimeType }));
|
||||
|
||||
showPlayer(objectUrl, label);
|
||||
}
|
||||
|
||||
async function discoverVideos() {
|
||||
// Don't interrupt playback with loading state - just show picker if video is loaded
|
||||
const hasVideo = Boolean(videoEl.src && videoEl.src !== window.location.href);
|
||||
if (!hasVideo) {
|
||||
showLoading("Discovering available videos...");
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceList = await app.listServerResources({});
|
||||
|
||||
const videoResources = resourceList.resources.filter((r) =>
|
||||
r.uri.startsWith("videos://"),
|
||||
);
|
||||
|
||||
if (videoResources.length === 0) {
|
||||
showError("No videos available. Server has no video resources.");
|
||||
return;
|
||||
}
|
||||
|
||||
videoPickerEl.innerHTML = '<option value="">Select a video...</option>';
|
||||
|
||||
videoResources.forEach((resource) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = resource.uri;
|
||||
option.text = resource.name || resource.description || resource.uri;
|
||||
videoPickerEl.appendChild(option);
|
||||
});
|
||||
|
||||
showPicker(hasVideo);
|
||||
} catch (err) {
|
||||
console.error("Error discovering videos:", err);
|
||||
showError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedVideo() {
|
||||
const selectedUri = videoPickerEl.value;
|
||||
if (!selectedUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading("Loading video...");
|
||||
|
||||
try {
|
||||
const selectedText =
|
||||
videoPickerEl.options[videoPickerEl.selectedIndex].text;
|
||||
await fetchAndPlayVideo(
|
||||
selectedUri,
|
||||
`${selectedText} (loaded via MCP resource)`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error loading video:", err);
|
||||
showError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
app.ontoolresult = async (result) => {
|
||||
console.info("Received tool result:", result);
|
||||
|
||||
|
|
@ -82,28 +186,10 @@ app.ontoolresult = async (result) => {
|
|||
showLoading("Fetching video from MCP resource...");
|
||||
|
||||
try {
|
||||
console.info("Requesting resource:", videoUri);
|
||||
|
||||
const resourceResult = await app.request(
|
||||
{ method: "resources/read", params: { uri: videoUri } },
|
||||
ReadResourceResultSchema,
|
||||
await fetchAndPlayVideo(
|
||||
videoUri,
|
||||
`Loaded via MCP resource (${description})`,
|
||||
);
|
||||
|
||||
const content = resourceResult.contents[0];
|
||||
if (!content || !("blob" in content)) {
|
||||
throw new Error("Resource response did not contain blob data");
|
||||
}
|
||||
|
||||
console.info("Resource received, blob size:", content.blob.length);
|
||||
|
||||
showLoading("Converting to data URI...");
|
||||
|
||||
const mimeType = content.mimeType || "video/mp4";
|
||||
const dataUri = `data:${mimeType};base64,${content.blob}`;
|
||||
|
||||
console.info("Data URI created, length:", dataUri.length);
|
||||
|
||||
showPlayer(dataUri, `Loaded via MCP resource (${description})`);
|
||||
} catch (err) {
|
||||
console.error("Error fetching resource:", err);
|
||||
showError(err instanceof Error ? err.message : String(err));
|
||||
|
|
@ -115,6 +201,16 @@ app.onerror = (err) => {
|
|||
showError(err instanceof Error ? err.message : String(err));
|
||||
};
|
||||
|
||||
videoPickerEl.addEventListener("change", () => {
|
||||
loadVideoBtn.disabled = !videoPickerEl.value;
|
||||
});
|
||||
|
||||
loadVideoBtn.addEventListener("click", loadSelectedVideo);
|
||||
|
||||
changeVideoBtn.addEventListener("click", () => {
|
||||
discoverVideos();
|
||||
});
|
||||
|
||||
function handleHostContextChanged(ctx: McpUiHostContext) {
|
||||
if (ctx.safeAreaInsets) {
|
||||
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue