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
8 changed files with 502 additions and 40 deletions
|
|
@ -21,6 +21,15 @@
|
||||||
<div id="player" class="player" style="display: none;">
|
<div id="player" class="player" style="display: none;">
|
||||||
<video id="video" controls></video>
|
<video id="video" controls></video>
|
||||||
<p id="video-info" class="video-info"></p>
|
<p id="video-info" class="video-info"></p>
|
||||||
|
<button id="change-video-btn" class="change-btn">Change Video</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="video-picker-container" class="picker-container" style="display: none;">
|
||||||
|
<h3>Available Videos</h3>
|
||||||
|
<select id="video-picker" class="video-picker">
|
||||||
|
<option value="">Select a video...</option>
|
||||||
|
</select>
|
||||||
|
<button id="load-video-btn" class="load-btn" disabled>Load Video</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/src/mcp-app.ts"></script>
|
<script type="module" src="/src/mcp-app.ts"></script>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,16 @@ export function createServer(): McpServer {
|
||||||
|
|
||||||
server.registerResource(
|
server.registerResource(
|
||||||
"video",
|
"video",
|
||||||
new ResourceTemplate("videos://{id}", { list: undefined }),
|
new ResourceTemplate("videos://{id}", {
|
||||||
|
list: async () => ({
|
||||||
|
resources: Object.entries(VIDEO_LIBRARY).map(([id, video]) => ({
|
||||||
|
uri: `videos://${id}`,
|
||||||
|
name: `video-${id}`,
|
||||||
|
description: `Video: ${video.description}`,
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
description: "Video served via MCP resource (base64 blob)",
|
description: "Video served via MCP resource (base64 blob)",
|
||||||
mimeType: "video/mp4",
|
mimeType: "video/mp4",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: 1rem;
|
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;
|
margin: 0;
|
||||||
padding: 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;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: var(--spacing-lg);
|
||||||
gap: 1rem;
|
gap: var(--spacing-md);
|
||||||
color: #6b7280;
|
color: var(--color-text-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border: 4px solid #e5e7eb;
|
border: 4px solid color-mix(in srgb, var(--color-text-primary) 20%, transparent);
|
||||||
border-top-color: #3b82f6;
|
border-top-color: var(--color-accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
@ -27,13 +27,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
padding: 1rem;
|
padding: var(--spacing-md);
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-title {
|
.error-title {
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-bold);
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 var(--spacing-sm) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error p {
|
.error p {
|
||||||
|
|
@ -43,12 +43,75 @@
|
||||||
.player video {
|
.player video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 480px;
|
max-height: 480px;
|
||||||
border-radius: 8px;
|
border-radius: var(--border-radius-md);
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-info {
|
.video-info {
|
||||||
margin-top: 0.5rem;
|
margin-top: var(--spacing-sm);
|
||||||
font-size: 0.875rem;
|
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 { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps";
|
||||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
import "./mcp-app.css";
|
import "./mcp-app.css";
|
||||||
|
|
||||||
|
|
@ -19,9 +18,19 @@ const loadingEl = document.getElementById("loading")!;
|
||||||
const loadingTextEl = document.getElementById("loading-text")!;
|
const loadingTextEl = document.getElementById("loading-text")!;
|
||||||
const errorEl = document.getElementById("error")!;
|
const errorEl = document.getElementById("error")!;
|
||||||
const errorMessageEl = document.getElementById("error-message")!;
|
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 playerEl = document.getElementById("player")!;
|
||||||
const videoEl = document.getElementById("video") as HTMLVideoElement;
|
const videoEl = document.getElementById("video") as HTMLVideoElement;
|
||||||
const videoInfoEl = document.getElementById("video-info")!;
|
const videoInfoEl = document.getElementById("video-info")!;
|
||||||
|
const changeVideoBtn = document.getElementById(
|
||||||
|
"change-video-btn",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// UI State Helpers
|
// UI State Helpers
|
||||||
|
|
@ -41,6 +50,7 @@ function showLoading(text: string) {
|
||||||
loadingTextEl.textContent = text;
|
loadingTextEl.textContent = text;
|
||||||
loadingEl.style.display = "flex";
|
loadingEl.style.display = "flex";
|
||||||
errorEl.style.display = "none";
|
errorEl.style.display = "none";
|
||||||
|
pickerContainerEl.style.display = "none";
|
||||||
playerEl.style.display = "none";
|
playerEl.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,15 +58,36 @@ function showError(message: string) {
|
||||||
errorMessageEl.textContent = message;
|
errorMessageEl.textContent = message;
|
||||||
loadingEl.style.display = "none";
|
loadingEl.style.display = "none";
|
||||||
errorEl.style.display = "block";
|
errorEl.style.display = "block";
|
||||||
|
pickerContainerEl.style.display = "none";
|
||||||
playerEl.style.display = "none";
|
playerEl.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPlayer(dataUri: string, info: string) {
|
function showPicker(keepPlayer = true) {
|
||||||
videoEl.src = dataUri;
|
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;
|
videoInfoEl.textContent = info;
|
||||||
loadingEl.style.display = "none";
|
loadingEl.style.display = "none";
|
||||||
errorEl.style.display = "none";
|
errorEl.style.display = "none";
|
||||||
|
pickerContainerEl.style.display = "none";
|
||||||
playerEl.style.display = "block";
|
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" });
|
const app = new App({ name: "Video Resource Player", version: "1.0.0" });
|
||||||
|
|
||||||
// Handle tool result - Requests a resource via resources/read and converts the
|
async function fetchAndPlayVideo(uri: string, label: string) {
|
||||||
// base64 blob to a data URI for use in the browser.
|
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) => {
|
app.ontoolresult = async (result) => {
|
||||||
console.info("Received tool result:", result);
|
console.info("Received tool result:", result);
|
||||||
|
|
||||||
|
|
@ -82,28 +186,10 @@ app.ontoolresult = async (result) => {
|
||||||
showLoading("Fetching video from MCP resource...");
|
showLoading("Fetching video from MCP resource...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.info("Requesting resource:", videoUri);
|
await fetchAndPlayVideo(
|
||||||
|
videoUri,
|
||||||
const resourceResult = await app.request(
|
`Loaded via MCP resource (${description})`,
|
||||||
{ method: "resources/read", params: { uri: videoUri } },
|
|
||||||
ReadResourceResultSchema,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error("Error fetching resource:", err);
|
console.error("Error fetching resource:", err);
|
||||||
showError(err instanceof Error ? err.message : String(err));
|
showError(err instanceof Error ? err.message : String(err));
|
||||||
|
|
@ -115,6 +201,16 @@ app.onerror = (err) => {
|
||||||
showError(err instanceof Error ? err.message : String(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) {
|
function handleHostContextChanged(ctx: McpUiHostContext) {
|
||||||
if (ctx.safeAreaInsets) {
|
if (ctx.safeAreaInsets) {
|
||||||
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
|
mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
|
||||||
|
|
|
||||||
|
|
@ -779,6 +779,27 @@ describe("App <-> AppBridge integration", () => {
|
||||||
expect(result.resources).toEqual(resources);
|
expect(result.resources).toEqual(resources);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("onlistresources handles listServerResources() calls from App", async () => {
|
||||||
|
const resources = [
|
||||||
|
{ uri: "test://res-1", name: "Resource 1" },
|
||||||
|
{ uri: "test://res-2", name: "Resource 2", mimeType: "video/mp4" },
|
||||||
|
];
|
||||||
|
const receivedRequests: unknown[] = [];
|
||||||
|
|
||||||
|
bridge.onlistresources = async (params) => {
|
||||||
|
receivedRequests.push(params);
|
||||||
|
return { resources };
|
||||||
|
};
|
||||||
|
|
||||||
|
await bridge.connect(bridgeTransport);
|
||||||
|
await app.connect(appTransport);
|
||||||
|
|
||||||
|
const result = await app.listServerResources({});
|
||||||
|
|
||||||
|
expect(receivedRequests).toHaveLength(1);
|
||||||
|
expect(result.resources).toEqual(resources);
|
||||||
|
});
|
||||||
|
|
||||||
it("onreadresource setter registers handler for resources/read requests", async () => {
|
it("onreadresource setter registers handler for resources/read requests", async () => {
|
||||||
const requestParams = { uri: "test://resource" };
|
const requestParams = { uri: "test://resource" };
|
||||||
const contents = [{ uri: "test://resource", text: "content" }];
|
const contents = [{ uri: "test://resource", text: "content" }];
|
||||||
|
|
@ -802,6 +823,28 @@ describe("App <-> AppBridge integration", () => {
|
||||||
expect(result.contents).toEqual(contents);
|
expect(result.contents).toEqual(contents);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("onreadresource handles readServerResource() calls from App", async () => {
|
||||||
|
const requestParams = { uri: "videos://bunny-1mb" };
|
||||||
|
const contents = [
|
||||||
|
{ uri: "videos://bunny-1mb", blob: "dmlkZW9kYXRh", mimeType: "video/mp4" },
|
||||||
|
];
|
||||||
|
const receivedRequests: unknown[] = [];
|
||||||
|
|
||||||
|
bridge.onreadresource = async (params) => {
|
||||||
|
receivedRequests.push(params);
|
||||||
|
return { contents };
|
||||||
|
};
|
||||||
|
|
||||||
|
await bridge.connect(bridgeTransport);
|
||||||
|
await app.connect(appTransport);
|
||||||
|
|
||||||
|
const result = await app.readServerResource(requestParams);
|
||||||
|
|
||||||
|
expect(receivedRequests).toHaveLength(1);
|
||||||
|
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||||
|
expect(result.contents).toEqual(contents);
|
||||||
|
});
|
||||||
|
|
||||||
it("onlistresourcetemplates setter registers handler for resources/templates/list requests", async () => {
|
it("onlistresourcetemplates setter registers handler for resources/templates/list requests", async () => {
|
||||||
const requestParams = {};
|
const requestParams = {};
|
||||||
const resourceTemplates = [
|
const resourceTemplates = [
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,60 @@ async function App_callServerTool_fetchWeather(app: App) {
|
||||||
//#endregion App_callServerTool_fetchWeather
|
//#endregion App_callServerTool_fetchWeather
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Read a video resource and play it.
|
||||||
|
*/
|
||||||
|
async function App_readServerResource_playVideo(
|
||||||
|
app: App,
|
||||||
|
videoElement: HTMLVideoElement,
|
||||||
|
) {
|
||||||
|
//#region App_readServerResource_playVideo
|
||||||
|
try {
|
||||||
|
const result = await app.readServerResource({
|
||||||
|
uri: "videos://bunny-1mb",
|
||||||
|
});
|
||||||
|
const content = result.contents[0];
|
||||||
|
if (content && "blob" in content) {
|
||||||
|
const binary = Uint8Array.from(atob(content.blob), (c) =>
|
||||||
|
c.charCodeAt(0),
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([binary], { type: content.mimeType || "video/mp4" }),
|
||||||
|
);
|
||||||
|
videoElement.src = url;
|
||||||
|
videoElement.play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to read resource:", error);
|
||||||
|
}
|
||||||
|
//#endregion App_readServerResource_playVideo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example: Discover available videos and build a picker UI.
|
||||||
|
*/
|
||||||
|
async function App_listServerResources_buildPicker(
|
||||||
|
app: App,
|
||||||
|
selectElement: HTMLSelectElement,
|
||||||
|
) {
|
||||||
|
//#region App_listServerResources_buildPicker
|
||||||
|
try {
|
||||||
|
const result = await app.listServerResources({});
|
||||||
|
const videoResources = result.resources.filter((r) =>
|
||||||
|
r.mimeType?.startsWith("video/"),
|
||||||
|
);
|
||||||
|
videoResources.forEach((resource) => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = resource.uri;
|
||||||
|
option.textContent = resource.description || resource.name;
|
||||||
|
selectElement.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to list resources:", error);
|
||||||
|
}
|
||||||
|
//#endregion App_listServerResources_buildPicker
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Example: Send a text message from user interaction.
|
* Example: Send a text message from user interaction.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
104
src/app.ts
104
src/app.ts
|
|
@ -11,10 +11,16 @@ import {
|
||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
EmptyResultSchema,
|
EmptyResultSchema,
|
||||||
Implementation,
|
Implementation,
|
||||||
|
ListResourcesRequest,
|
||||||
|
ListResourcesResult,
|
||||||
|
ListResourcesResultSchema,
|
||||||
ListToolsRequest,
|
ListToolsRequest,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
LoggingMessageNotification,
|
LoggingMessageNotification,
|
||||||
PingRequestSchema,
|
PingRequestSchema,
|
||||||
|
ReadResourceRequest,
|
||||||
|
ReadResourceResult,
|
||||||
|
ReadResourceResultSchema,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { AppNotification, AppRequest, AppResult } from "./types";
|
import { AppNotification, AppRequest, AppResult } from "./types";
|
||||||
import { PostMessageTransport } from "./message-transport";
|
import { PostMessageTransport } from "./message-transport";
|
||||||
|
|
@ -739,6 +745,104 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a resource from the originating MCP server (proxied through the host).
|
||||||
|
*
|
||||||
|
* Apps can read resources to access files, data, or other content provided by
|
||||||
|
* the MCP server. Resources are identified by URI (e.g., `file:///path/to/file`
|
||||||
|
* or custom schemes like `videos://bunny-1mb`). The host proxies the request to
|
||||||
|
* the actual MCP server and returns the resource content.
|
||||||
|
*
|
||||||
|
* @param params - Resource URI to read
|
||||||
|
* @param options - Request options (timeout, etc.)
|
||||||
|
* @returns Resource content with URI, name, description, mimeType, and contents array
|
||||||
|
*
|
||||||
|
* @throws {Error} If the resource does not exist on the server
|
||||||
|
* @throws {Error} If the request times out or the connection is lost
|
||||||
|
* @throws {Error} If the host rejects the request
|
||||||
|
*
|
||||||
|
* @example Read a video resource and play it
|
||||||
|
* ```ts source="./app.examples.ts#App_readServerResource_playVideo"
|
||||||
|
* try {
|
||||||
|
* const result = await app.readServerResource({
|
||||||
|
* uri: "videos://bunny-1mb",
|
||||||
|
* });
|
||||||
|
* const content = result.contents[0];
|
||||||
|
* if (content && "blob" in content) {
|
||||||
|
* const binary = Uint8Array.from(atob(content.blob), (c) =>
|
||||||
|
* c.charCodeAt(0),
|
||||||
|
* );
|
||||||
|
* const url = URL.createObjectURL(
|
||||||
|
* new Blob([binary], { type: content.mimeType || "video/mp4" }),
|
||||||
|
* );
|
||||||
|
* videoElement.src = url;
|
||||||
|
* videoElement.play();
|
||||||
|
* }
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error("Failed to read resource:", error);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link listServerResources `listServerResources`} to discover available resources
|
||||||
|
*/
|
||||||
|
async readServerResource(
|
||||||
|
params: ReadResourceRequest["params"],
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<ReadResourceResult> {
|
||||||
|
return await this.request(
|
||||||
|
{ method: "resources/read", params },
|
||||||
|
ReadResourceResultSchema,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available resources from the originating MCP server (proxied through the host).
|
||||||
|
*
|
||||||
|
* Apps can list resources to discover what content is available on the MCP server.
|
||||||
|
* This enables dynamic resource discovery and building resource browsers or pickers.
|
||||||
|
* The host proxies the request to the actual MCP server and returns the resource list.
|
||||||
|
*
|
||||||
|
* Results may be paginated using the `cursor` parameter for servers with many resources.
|
||||||
|
*
|
||||||
|
* @param params - Optional parameters (empty object `{}` for all resources, or `{ cursor }` for pagination)
|
||||||
|
* @param options - Request options (timeout, etc.)
|
||||||
|
* @returns List of resources with their URIs, names, descriptions, mimeTypes, and optional pagination cursor
|
||||||
|
*
|
||||||
|
* @throws {Error} If the request times out or the connection is lost
|
||||||
|
* @throws {Error} If the host rejects the request
|
||||||
|
*
|
||||||
|
* @example Discover available videos and build a picker UI
|
||||||
|
* ```ts source="./app.examples.ts#App_listServerResources_buildPicker"
|
||||||
|
* try {
|
||||||
|
* const result = await app.listServerResources({});
|
||||||
|
* const videoResources = result.resources.filter((r) =>
|
||||||
|
* r.mimeType?.startsWith("video/"),
|
||||||
|
* );
|
||||||
|
* videoResources.forEach((resource) => {
|
||||||
|
* const option = document.createElement("option");
|
||||||
|
* option.value = resource.uri;
|
||||||
|
* option.textContent = resource.description || resource.name;
|
||||||
|
* selectElement.appendChild(option);
|
||||||
|
* });
|
||||||
|
* } catch (error) {
|
||||||
|
* console.error("Failed to list resources:", error);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link readServerResource `readServerResource`} to read a specific resource
|
||||||
|
*/
|
||||||
|
async listServerResources(
|
||||||
|
params: ListResourcesRequest["params"],
|
||||||
|
options?: RequestOptions,
|
||||||
|
): Promise<ListResourcesResult> {
|
||||||
|
return await this.request(
|
||||||
|
{ method: "resources/list", params },
|
||||||
|
ListResourcesResultSchema,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message to the host's chat interface.
|
* Send a message to the host's chat interface.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue