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;">
|
||||
<video id="video" controls></video>
|
||||
<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>
|
||||
</main>
|
||||
<script type="module" src="/src/mcp-app.ts"></script>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,16 @@ export function createServer(): McpServer {
|
|||
|
||||
server.registerResource(
|
||||
"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)",
|
||||
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;
|
||||
}
|
||||
|
||||
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`;
|
||||
|
|
|
|||
|
|
@ -779,6 +779,27 @@ describe("App <-> AppBridge integration", () => {
|
|||
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 () => {
|
||||
const requestParams = { uri: "test://resource" };
|
||||
const contents = [{ uri: "test://resource", text: "content" }];
|
||||
|
|
@ -802,6 +823,28 @@ describe("App <-> AppBridge integration", () => {
|
|||
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 () => {
|
||||
const requestParams = {};
|
||||
const resourceTemplates = [
|
||||
|
|
|
|||
|
|
@ -297,6 +297,60 @@ async function App_callServerTool_fetchWeather(app: App) {
|
|||
//#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.
|
||||
*/
|
||||
|
|
|
|||
104
src/app.ts
104
src/app.ts
|
|
@ -11,10 +11,16 @@ import {
|
|||
CallToolResultSchema,
|
||||
EmptyResultSchema,
|
||||
Implementation,
|
||||
ListResourcesRequest,
|
||||
ListResourcesResult,
|
||||
ListResourcesResultSchema,
|
||||
ListToolsRequest,
|
||||
ListToolsRequestSchema,
|
||||
LoggingMessageNotification,
|
||||
PingRequestSchema,
|
||||
ReadResourceRequest,
|
||||
ReadResourceResult,
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AppNotification, AppRequest, AppResult } from "./types";
|
||||
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.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue