feat call tool result from tools tab
This commit is contained in:
parent
00791446a8
commit
a31ae3e767
8 changed files with 1172 additions and 283 deletions
|
|
@ -30,6 +30,7 @@ import {
|
|||
hasValidMetaPrefix,
|
||||
isReservedMetaKey,
|
||||
} from "@/utils/metaUtils";
|
||||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
||||
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||
|
|
@ -101,6 +102,27 @@ import MetadataTab from "./components/MetadataTab";
|
|||
|
||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||
|
||||
type PrefilledAppsToolCall = {
|
||||
id: number;
|
||||
toolName: string;
|
||||
params: Record<string, unknown>;
|
||||
result: CompatibilityCallToolResult;
|
||||
};
|
||||
|
||||
const hasAppResourceUri = (tool: Tool): boolean => {
|
||||
return Boolean(getToolUiResourceUri(tool));
|
||||
};
|
||||
|
||||
const cloneToolParams = (
|
||||
source: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
try {
|
||||
return structuredClone(source);
|
||||
} catch {
|
||||
return { ...source };
|
||||
}
|
||||
};
|
||||
|
||||
const filterReservedMetadata = (
|
||||
metadata: Record<string, string>,
|
||||
): Record<string, string> => {
|
||||
|
|
@ -137,6 +159,8 @@ const App = () => {
|
|||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [toolResult, setToolResult] =
|
||||
useState<CompatibilityCallToolResult | null>(null);
|
||||
const [prefilledAppsToolCall, setPrefilledAppsToolCall] =
|
||||
useState<PrefilledAppsToolCall | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string | null>>({
|
||||
resources: null,
|
||||
prompts: null,
|
||||
|
|
@ -291,6 +315,7 @@ const App = () => {
|
|||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
|
||||
const progressTokenRef = useRef(0);
|
||||
const prefilledAppsToolCallIdRef = useRef(0);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
|
@ -977,7 +1002,7 @@ const App = () => {
|
|||
params: Record<string, unknown>,
|
||||
toolMetadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => {
|
||||
): Promise<CompatibilityCallToolResult> => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
try {
|
||||
|
|
@ -1045,7 +1070,7 @@ const App = () => {
|
|||
"_meta" in (response as Record<string, unknown>)
|
||||
? ((response as { _meta?: Record<string, unknown> })._meta ?? {})
|
||||
: undefined;
|
||||
setToolResult({
|
||||
let latestToolResult: CompatibilityCallToolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
|
|
@ -1056,7 +1081,8 @@ const App = () => {
|
|||
...(initialResponseMeta || {}),
|
||||
"io.modelcontextprotocol/related-task": { taskId },
|
||||
},
|
||||
} as CompatibilityCallToolResult);
|
||||
};
|
||||
setToolResult(latestToolResult);
|
||||
|
||||
// Polling loop
|
||||
let taskCompleted = false;
|
||||
|
|
@ -1093,12 +1119,13 @@ const App = () => {
|
|||
CompatibilityCallToolResultSchema,
|
||||
);
|
||||
console.log(`Result received for task ${taskId}:`, result);
|
||||
setToolResult(result as CompatibilityCallToolResult);
|
||||
latestToolResult = result as CompatibilityCallToolResult;
|
||||
setToolResult(latestToolResult);
|
||||
|
||||
// Refresh tasks list to show completed state
|
||||
void listTasks();
|
||||
} else {
|
||||
setToolResult({
|
||||
latestToolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
|
|
@ -1106,7 +1133,8 @@ const App = () => {
|
|||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
};
|
||||
setToolResult(latestToolResult);
|
||||
// Refresh tasks list to show failed/cancelled state
|
||||
void listTasks();
|
||||
}
|
||||
|
|
@ -1120,7 +1148,7 @@ const App = () => {
|
|||
? ((response as { _meta?: Record<string, unknown> })._meta ??
|
||||
{})
|
||||
: undefined;
|
||||
setToolResult({
|
||||
latestToolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
|
|
@ -1131,13 +1159,14 @@ const App = () => {
|
|||
...(pollingResponseMeta || {}),
|
||||
"io.modelcontextprotocol/related-task": { taskId },
|
||||
},
|
||||
} as CompatibilityCallToolResult);
|
||||
};
|
||||
setToolResult(latestToolResult);
|
||||
// Refresh tasks list to show progress
|
||||
void listTasks();
|
||||
}
|
||||
} catch (pollingError) {
|
||||
console.error("Error polling task status:", pollingError);
|
||||
setToolResult({
|
||||
latestToolResult = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
|
|
@ -1145,16 +1174,22 @@ const App = () => {
|
|||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
};
|
||||
setToolResult(latestToolResult);
|
||||
taskCompleted = true;
|
||||
}
|
||||
}
|
||||
setIsPollingTask(false);
|
||||
// Clear any validation errors since tool execution completed
|
||||
setErrors((prev) => ({ ...prev, tools: null }));
|
||||
return latestToolResult;
|
||||
} else {
|
||||
setToolResult(response as CompatibilityCallToolResult);
|
||||
const directResult = response as CompatibilityCallToolResult;
|
||||
setToolResult(directResult);
|
||||
// Clear any validation errors since tool execution completed
|
||||
setErrors((prev) => ({ ...prev, tools: null }));
|
||||
return directResult;
|
||||
}
|
||||
// Clear any validation errors since tool execution completed
|
||||
setErrors((prev) => ({ ...prev, tools: null }));
|
||||
} catch (e) {
|
||||
const toolResult: CompatibilityCallToolResult = {
|
||||
content: [
|
||||
|
|
@ -1168,6 +1203,7 @@ const App = () => {
|
|||
setToolResult(toolResult);
|
||||
// Clear validation errors - tool execution errors are shown in ToolResults
|
||||
setErrors((prev) => ({ ...prev, tools: null }));
|
||||
return toolResult;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1508,7 +1544,26 @@ const App = () => {
|
|||
) => {
|
||||
clearError("tools");
|
||||
setToolResult(null);
|
||||
await callTool(name, params, metadata, runAsTask);
|
||||
const result = await callTool(
|
||||
name,
|
||||
params,
|
||||
metadata,
|
||||
runAsTask,
|
||||
);
|
||||
const calledTool = tools.find(
|
||||
(tool) => tool.name === name,
|
||||
);
|
||||
if (calledTool && hasAppResourceUri(calledTool)) {
|
||||
setPrefilledAppsToolCall({
|
||||
id: ++prefilledAppsToolCallIdRef.current,
|
||||
toolName: name,
|
||||
params: cloneToolParams(params),
|
||||
result,
|
||||
});
|
||||
} else {
|
||||
setPrefilledAppsToolCall(null);
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={(tool) => {
|
||||
|
|
@ -1552,6 +1607,22 @@ const App = () => {
|
|||
clearError("tools");
|
||||
listTools();
|
||||
}}
|
||||
callTool={async (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => {
|
||||
clearError("tools");
|
||||
setToolResult(null);
|
||||
return callTool(name, params, metadata, runAsTask);
|
||||
}}
|
||||
prefilledToolCall={prefilledAppsToolCall}
|
||||
onPrefilledToolCallConsumed={(callId) => {
|
||||
setPrefilledAppsToolCall((prev) =>
|
||||
prev?.id === callId ? null : prev,
|
||||
);
|
||||
}}
|
||||
error={errors.tools}
|
||||
mcpClient={mcpClient}
|
||||
onNotification={(notification) => {
|
||||
|
|
|
|||
288
client/src/__tests__/App.toolsAppsPrefill.test.tsx
Normal file
288
client/src/__tests__/App.toolsAppsPrefill.test.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import App from "../App";
|
||||
import { useConnection } from "../lib/hooks/useConnection";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
|
||||
type ToolListEntry = {
|
||||
name: string;
|
||||
inputSchema: {
|
||||
type: "object";
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
_meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let latestAppsTabProps:
|
||||
| {
|
||||
tools: ToolListEntry[];
|
||||
prefilledToolCall?: {
|
||||
id: number;
|
||||
toolName: string;
|
||||
params: Record<string, unknown>;
|
||||
result: {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
};
|
||||
} | null;
|
||||
onPrefilledToolCallConsumed?: (callId: number) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Mock auth dependencies first
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/oauth-state-machine", () => ({
|
||||
OAuthStateMachine: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/auth", () => ({
|
||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||
tokens: jest.fn().mockResolvedValue(null),
|
||||
clear: jest.fn(),
|
||||
})),
|
||||
DebugInspectorOAuthClientProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../utils/configUtils", () => ({
|
||||
...jest.requireActual("../utils/configUtils"),
|
||||
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
|
||||
getMCPProxyAuthToken: jest.fn(() => ({
|
||||
token: "",
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
})),
|
||||
getInitialTransportType: jest.fn(() => "stdio"),
|
||||
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
|
||||
getInitialCommand: jest.fn(() => "mcp-server-everything"),
|
||||
getInitialArgs: jest.fn(() => ""),
|
||||
initializeInspectorConfig: jest.fn(() => ({})),
|
||||
saveInspectorConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/hooks/useDraggablePane", () => ({
|
||||
useDraggablePane: () => ({
|
||||
height: 300,
|
||||
handleDragStart: jest.fn(),
|
||||
}),
|
||||
useDraggableSidebar: () => ({
|
||||
width: 320,
|
||||
isDragging: false,
|
||||
handleDragStart: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../components/Sidebar", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Sidebar</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ResourcesTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>ResourcesTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/PromptsTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>PromptsTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/TasksTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>TasksTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ConsoleTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>ConsoleTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/PingTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>PingTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/SamplingTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>SamplingTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/RootsTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>RootsTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ElicitationTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>ElicitationTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/MetadataTab", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>MetadataTab</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/AuthDebugger", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>AuthDebugger</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/HistoryAndNotifications", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>HistoryAndNotifications</div>,
|
||||
}));
|
||||
|
||||
jest.mock("../components/ToolsTab", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
listTools,
|
||||
callTool,
|
||||
}: {
|
||||
listTools: () => void;
|
||||
callTool: (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => Promise<unknown>;
|
||||
}) => (
|
||||
<div data-testid="tools-tab">
|
||||
<button type="button" onClick={listTools}>
|
||||
mock list tools
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void callTool("weatherApp", { city: "Lisbon" });
|
||||
}}
|
||||
>
|
||||
mock run app tool
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("../components/AppsTab", () => ({
|
||||
__esModule: true,
|
||||
default: (props: typeof latestAppsTabProps) => {
|
||||
latestAppsTabProps = props;
|
||||
const prefilled =
|
||||
props && "prefilledToolCall" in props ? props.prefilledToolCall : null;
|
||||
const tools = props && "tools" in props ? props.tools : [];
|
||||
|
||||
return (
|
||||
<div data-testid="apps-tab">
|
||||
<div data-testid="apps-tools">{JSON.stringify(tools)}</div>
|
||||
<div data-testid="apps-prefilled">
|
||||
{JSON.stringify(prefilled ?? null)}
|
||||
</div>
|
||||
{prefilled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props?.onPrefilledToolCallConsumed?.(prefilled.id)}
|
||||
>
|
||||
consume prefilled
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
|
||||
|
||||
jest.mock("../lib/hooks/useConnection", () => ({
|
||||
useConnection: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("App - Tools to Apps prefilled handoff", () => {
|
||||
const mockUseConnection = jest.mocked(useConnection);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
latestAppsTabProps = undefined;
|
||||
window.location.hash = "#tools";
|
||||
});
|
||||
|
||||
it("passes prefilled call data to AppsTab for tools using _meta['ui/resourceUri']", async () => {
|
||||
const makeRequest = jest.fn(async (request: { method: string }) => {
|
||||
if (request.method === "tools/list") {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "weatherApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: {
|
||||
"ui/resourceUri": "ui://weather-app",
|
||||
},
|
||||
},
|
||||
],
|
||||
nextCursor: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (request.method === "tools/call") {
|
||||
return {
|
||||
content: [{ type: "text", text: "weather result" }],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected method: ${request.method}`);
|
||||
});
|
||||
|
||||
mockUseConnection.mockReturnValue({
|
||||
connectionStatus: "connected",
|
||||
serverCapabilities: { tools: { listChanged: true } },
|
||||
serverImplementation: null,
|
||||
mcpClient: {
|
||||
request: jest.fn(),
|
||||
notification: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as Client,
|
||||
requestHistory: [],
|
||||
clearRequestHistory: jest.fn(),
|
||||
makeRequest,
|
||||
cancelTask: jest.fn(),
|
||||
listTasks: jest.fn(),
|
||||
sendNotification: jest.fn(),
|
||||
handleCompletion: jest.fn(),
|
||||
completionsSupported: false,
|
||||
connect: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
} as ReturnType<typeof useConnection>);
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /mock list tools/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const tools = JSON.parse(
|
||||
screen.getByTestId("apps-tools").textContent || "[]",
|
||||
);
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe("weatherApp");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /mock run app tool/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const prefilled = JSON.parse(
|
||||
screen.getByTestId("apps-prefilled").textContent || "null",
|
||||
);
|
||||
expect(prefilled.toolName).toBe("weatherApp");
|
||||
expect(prefilled.params).toEqual({ city: "Lisbon" });
|
||||
expect(prefilled.result.content[0].text).toBe("weather result");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /consume prefilled/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("apps-prefilled")).toHaveTextContent("null");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import {
|
||||
Tool,
|
||||
ContentBlock,
|
||||
CompatibilityCallToolResult,
|
||||
CallToolResult,
|
||||
CallToolResultSchema,
|
||||
ServerNotification,
|
||||
|
|
@ -26,6 +27,7 @@ interface AppRendererProps {
|
|||
tool: Tool;
|
||||
mcpClient: Client | null;
|
||||
toolInput?: Record<string, unknown>;
|
||||
toolResult?: CompatibilityCallToolResult | null;
|
||||
onNotification?: (notification: ServerNotification) => void;
|
||||
}
|
||||
|
||||
|
|
@ -34,13 +36,32 @@ const AppRenderer = ({
|
|||
tool,
|
||||
mcpClient,
|
||||
toolInput,
|
||||
toolResult,
|
||||
onNotification,
|
||||
}: AppRendererProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toolResult, setToolResult] = useState<CallToolResult | undefined>();
|
||||
const latestRunIdRef = useRef(0);
|
||||
const { toast } = useToast();
|
||||
|
||||
const normalizedToolResult = useMemo<CallToolResult | undefined>(() => {
|
||||
if (!toolResult) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if ("content" in toolResult) {
|
||||
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||
return parsedResult.success ? parsedResult.data : undefined;
|
||||
}
|
||||
|
||||
if ("toolResult" in toolResult) {
|
||||
const parsedResult = CallToolResultSchema.safeParse(
|
||||
toolResult.toolResult,
|
||||
);
|
||||
return parsedResult.success ? parsedResult.data : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [toolResult]);
|
||||
|
||||
const hostContext: McpUiHostContext = useMemo(
|
||||
() => ({
|
||||
theme: document.documentElement.classList.contains("dark")
|
||||
|
|
@ -89,67 +110,6 @@ const AppRenderer = ({
|
|||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!mcpClient) {
|
||||
setToolResult(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = ++latestRunIdRef.current;
|
||||
const abortController = new AbortController();
|
||||
setToolResult(undefined);
|
||||
|
||||
const runTool = async () => {
|
||||
try {
|
||||
const result = await mcpClient.request(
|
||||
{
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: tool.name,
|
||||
arguments: toolInput ?? {},
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
if (
|
||||
abortController.signal.aborted ||
|
||||
runId !== latestRunIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setToolResult(result);
|
||||
} catch (runError) {
|
||||
if (
|
||||
abortController.signal.aborted ||
|
||||
runId !== latestRunIdRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
runError instanceof Error ? runError.message : String(runError);
|
||||
setToolResult({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void runTool();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [mcpClient, tool.name, toolInput]);
|
||||
|
||||
if (!mcpClient) {
|
||||
return (
|
||||
<Alert>
|
||||
|
|
@ -180,7 +140,7 @@ const AppRenderer = ({
|
|||
toolName={tool.name}
|
||||
hostContext={hostContext}
|
||||
toolInput={toolInput}
|
||||
toolResult={toolResult}
|
||||
toolResult={normalizedToolResult}
|
||||
sandbox={{
|
||||
url: new URL(sandboxPath, window.location.origin),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ import {
|
|||
AlertCircle,
|
||||
X,
|
||||
Play,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from "lucide-react";
|
||||
import { Tool, ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
Tool,
|
||||
ServerNotification,
|
||||
CompatibilityCallToolResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import AppRenderer from "./AppRenderer";
|
||||
|
|
@ -40,6 +45,19 @@ interface AppsTabProps {
|
|||
sandboxPath: string;
|
||||
tools: Tool[];
|
||||
listTools: () => void;
|
||||
callTool: (
|
||||
name: string,
|
||||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => Promise<CompatibilityCallToolResult>;
|
||||
prefilledToolCall?: {
|
||||
id: number;
|
||||
toolName: string;
|
||||
params: Record<string, unknown>;
|
||||
result: CompatibilityCallToolResult;
|
||||
} | null;
|
||||
onPrefilledToolCallConsumed?: (callId: number) => void;
|
||||
error: string | null;
|
||||
mcpClient: Client | null;
|
||||
onNotification?: (notification: ServerNotification) => void;
|
||||
|
|
@ -50,10 +68,23 @@ const hasUIMetadata = (tool: Tool): boolean => {
|
|||
return !!getToolUiResourceUri(tool);
|
||||
};
|
||||
|
||||
const cloneToolParams = (
|
||||
source: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
try {
|
||||
return structuredClone(source);
|
||||
} catch {
|
||||
return { ...source };
|
||||
}
|
||||
};
|
||||
|
||||
const AppsTab = ({
|
||||
sandboxPath,
|
||||
tools,
|
||||
listTools,
|
||||
callTool,
|
||||
prefilledToolCall,
|
||||
onPrefilledToolCallConsumed,
|
||||
error,
|
||||
mcpClient,
|
||||
onNotification,
|
||||
|
|
@ -62,9 +93,38 @@ const AppsTab = ({
|
|||
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isAppOpen, setIsAppOpen] = useState(false);
|
||||
const [isOpeningApp, setIsOpeningApp] = useState(false);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [hasValidationErrors, setHasValidationErrors] = useState(false);
|
||||
const [submittedParams, setSubmittedParams] = useState<
|
||||
Record<string, unknown> | undefined
|
||||
>(undefined);
|
||||
const [submittedToolResult, setSubmittedToolResult] =
|
||||
useState<CompatibilityCallToolResult | null>(null);
|
||||
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
|
||||
const openAppRunIdRef = useRef(0);
|
||||
const prefillingParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||
const consumedPrefilledCallIdRef = useRef<number | null>(null);
|
||||
|
||||
const buildInitialParams = useCallback((tool: Tool) => {
|
||||
const initialParams = Object.entries(tool.inputSchema.properties ?? []).map(
|
||||
([key, value]) => {
|
||||
const resolvedValue = resolveRef(
|
||||
value as JsonSchemaType,
|
||||
tool.inputSchema as JsonSchemaType,
|
||||
);
|
||||
return [
|
||||
key,
|
||||
generateDefaultValue(
|
||||
resolvedValue,
|
||||
key,
|
||||
tool.inputSchema as JsonSchemaType,
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
return Object.fromEntries(initialParams);
|
||||
}, []);
|
||||
|
||||
// Function to check if any form has validation errors
|
||||
const checkValidationErrors = useCallback(() => {
|
||||
|
|
@ -89,63 +149,145 @@ const AppsTab = ({
|
|||
if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) {
|
||||
setSelectedTool(null);
|
||||
setIsAppOpen(false);
|
||||
setSubmittedParams(undefined);
|
||||
setSubmittedToolResult(null);
|
||||
}
|
||||
}, [tools, selectedTool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTool) {
|
||||
const initialParams = Object.entries(
|
||||
selectedTool.inputSchema.properties ?? [],
|
||||
).map(([key, value]) => {
|
||||
// First resolve any $ref references
|
||||
const resolvedValue = resolveRef(
|
||||
value as JsonSchemaType,
|
||||
selectedTool.inputSchema as JsonSchemaType,
|
||||
);
|
||||
return [
|
||||
key,
|
||||
generateDefaultValue(
|
||||
resolvedValue,
|
||||
key,
|
||||
selectedTool.inputSchema as JsonSchemaType,
|
||||
),
|
||||
];
|
||||
});
|
||||
setParams(Object.fromEntries(initialParams));
|
||||
const prefillingParams = prefillingParamsRef.current;
|
||||
if (prefillingParams) {
|
||||
setParams(prefillingParams);
|
||||
prefillingParamsRef.current = null;
|
||||
} else {
|
||||
setParams(buildInitialParams(selectedTool));
|
||||
}
|
||||
setHasValidationErrors(false);
|
||||
formRefs.current = {};
|
||||
} else {
|
||||
setParams({});
|
||||
setIsAppOpen(false);
|
||||
setSubmittedParams(undefined);
|
||||
setSubmittedToolResult(null);
|
||||
}
|
||||
}, [selectedTool]);
|
||||
}, [buildInitialParams, selectedTool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefilledToolCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumedPrefilledCallIdRef.current === prefilledToolCall.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingTool = appTools.find(
|
||||
(tool) => tool.name === prefilledToolCall.toolName,
|
||||
);
|
||||
if (!matchingTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hydratedParams = cloneToolParams(prefilledToolCall.params);
|
||||
|
||||
openAppRunIdRef.current += 1;
|
||||
setIsOpeningApp(false);
|
||||
prefillingParamsRef.current = hydratedParams;
|
||||
setSelectedTool(matchingTool);
|
||||
setSubmittedParams(hydratedParams);
|
||||
setSubmittedToolResult(prefilledToolCall.result);
|
||||
setIsAppOpen(true);
|
||||
setIsMaximized(false);
|
||||
consumedPrefilledCallIdRef.current = prefilledToolCall.id;
|
||||
onPrefilledToolCallConsumed?.(prefilledToolCall.id);
|
||||
}, [appTools, onPrefilledToolCallConsumed, prefilledToolCall]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
listTools();
|
||||
}, [listTools]);
|
||||
|
||||
const executeToolAndOpenApp = useCallback(
|
||||
async (tool: Tool, toolParams: Record<string, unknown>) => {
|
||||
const runId = ++openAppRunIdRef.current;
|
||||
const runParams = cloneToolParams(toolParams);
|
||||
prefillingParamsRef.current = null;
|
||||
setIsOpeningApp(true);
|
||||
setSubmittedParams(runParams);
|
||||
setSubmittedToolResult(null);
|
||||
try {
|
||||
const result = await callTool(tool.name, runParams);
|
||||
|
||||
if (runId !== openAppRunIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmittedParams(runParams);
|
||||
setSubmittedToolResult(result);
|
||||
setIsAppOpen(true);
|
||||
} catch {
|
||||
if (runId !== openAppRunIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmittedToolResult(null);
|
||||
setIsAppOpen(false);
|
||||
} finally {
|
||||
if (runId === openAppRunIdRef.current) {
|
||||
setIsOpeningApp(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[callTool],
|
||||
);
|
||||
|
||||
const handleCloseApp = useCallback(() => {
|
||||
openAppRunIdRef.current += 1;
|
||||
setIsOpeningApp(false);
|
||||
setIsAppOpen(false);
|
||||
setSubmittedToolResult(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenApp = useCallback(() => {
|
||||
if (!checkValidationErrors()) {
|
||||
setIsAppOpen(true);
|
||||
const handleOpenApp = useCallback(async () => {
|
||||
if (!selectedTool || checkValidationErrors()) {
|
||||
return;
|
||||
}
|
||||
}, [checkValidationErrors]);
|
||||
|
||||
const handleSelectTool = useCallback((tool: Tool) => {
|
||||
setSelectedTool(tool);
|
||||
const hasFields =
|
||||
tool.inputSchema.properties &&
|
||||
Object.keys(tool.inputSchema.properties).length > 0;
|
||||
setIsAppOpen(!hasFields);
|
||||
}, []);
|
||||
await executeToolAndOpenApp(selectedTool, params);
|
||||
}, [checkValidationErrors, executeToolAndOpenApp, params, selectedTool]);
|
||||
|
||||
const handleSelectTool = useCallback(
|
||||
(tool: Tool) => {
|
||||
openAppRunIdRef.current += 1;
|
||||
setIsOpeningApp(false);
|
||||
prefillingParamsRef.current = null;
|
||||
setSelectedTool(tool);
|
||||
setSubmittedParams(undefined);
|
||||
setSubmittedToolResult(null);
|
||||
const hasFields =
|
||||
tool.inputSchema.properties &&
|
||||
Object.keys(tool.inputSchema.properties).length > 0;
|
||||
|
||||
if (hasFields) {
|
||||
setIsAppOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialParams = buildInitialParams(tool);
|
||||
void executeToolAndOpenApp(tool, initialParams);
|
||||
},
|
||||
[buildInitialParams, executeToolAndOpenApp],
|
||||
);
|
||||
|
||||
const handleDeselectTool = useCallback(() => {
|
||||
openAppRunIdRef.current += 1;
|
||||
setIsOpeningApp(false);
|
||||
prefillingParamsRef.current = null;
|
||||
setSelectedTool(null);
|
||||
setIsAppOpen(false);
|
||||
setIsMaximized(false);
|
||||
setSubmittedParams(undefined);
|
||||
setSubmittedToolResult(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -470,12 +612,16 @@ const AppsTab = ({
|
|||
})}
|
||||
|
||||
<Button
|
||||
onClick={handleOpenApp}
|
||||
onClick={() => void handleOpenApp()}
|
||||
className="w-full"
|
||||
disabled={hasValidationErrors}
|
||||
disabled={hasValidationErrors || isOpeningApp}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Open App
|
||||
{isOpeningApp ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isOpeningApp ? "Opening App..." : "Open App"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -497,7 +643,8 @@ const AppsTab = ({
|
|||
sandboxPath={sandboxPath}
|
||||
tool={selectedTool}
|
||||
mcpClient={mcpClient}
|
||||
toolInput={params}
|
||||
toolInput={submittedParams}
|
||||
toolResult={submittedToolResult}
|
||||
onNotification={onNotification}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const ToolsTab = ({
|
|||
params: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>,
|
||||
runAsTask?: boolean,
|
||||
) => Promise<void>;
|
||||
) => Promise<CompatibilityCallToolResult>;
|
||||
selectedTool: Tool | null;
|
||||
setSelectedTool: (tool: Tool | null) => void;
|
||||
toolResult: CompatibilityCallToolResult | null;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|||
import "@testing-library/jest-dom";
|
||||
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||
import AppRenderer from "../AppRenderer";
|
||||
import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
Tool,
|
||||
CompatibilityCallToolResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { RequestHandlerExtra } from "@mcp-ui/client";
|
||||
import { McpUiMessageResult } from "@modelcontextprotocol/ext-apps";
|
||||
|
|
@ -16,7 +19,7 @@ type BridgeEvent = {
|
|||
type MockMcpUiRendererProps = {
|
||||
toolName: string;
|
||||
toolInput?: Record<string, unknown>;
|
||||
toolResult?: CallToolResult;
|
||||
toolResult?: CompatibilityCallToolResult;
|
||||
onMessage?: (
|
||||
params: { role: "user"; content: { type: "text"; text: string }[] },
|
||||
extra: RequestHandlerExtra,
|
||||
|
|
@ -111,16 +114,6 @@ jest.mock("@mcp-ui/client", () => {
|
|||
};
|
||||
});
|
||||
|
||||
const createDeferred = <T,>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe("AppRenderer", () => {
|
||||
const mockTool: Tool = {
|
||||
name: "testApp",
|
||||
|
|
@ -137,12 +130,6 @@ describe("AppRenderer", () => {
|
|||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
const mockMcpClient = {
|
||||
request: jest.fn(
|
||||
() =>
|
||||
new Promise<CallToolResult>(() => {
|
||||
// Intentionally unresolved for baseline rendering tests.
|
||||
}),
|
||||
),
|
||||
getServerCapabilities: jest.fn().mockReturnValue({}),
|
||||
setNotificationHandler: jest.fn(),
|
||||
} as unknown as Client;
|
||||
|
|
@ -151,6 +138,7 @@ describe("AppRenderer", () => {
|
|||
sandboxPath: "/sandbox",
|
||||
tool: mockTool,
|
||||
mcpClient: mockMcpClient,
|
||||
toolResult: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -187,38 +175,19 @@ describe("AppRenderer", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should call tools/call and send tool input/result after app initialization", async () => {
|
||||
const mockResult: CallToolResult = {
|
||||
it("should send provided tool input and tool result after app initialization", async () => {
|
||||
const result: CompatibilityCallToolResult = {
|
||||
content: [{ type: "text", text: "Budget initialized" }],
|
||||
};
|
||||
const requestMock = jest.fn().mockResolvedValue(mockResult);
|
||||
const mcpClient = {
|
||||
...mockMcpClient,
|
||||
request: requestMock,
|
||||
} as unknown as Client;
|
||||
|
||||
render(
|
||||
<AppRenderer
|
||||
{...defaultProps}
|
||||
mcpClient={mcpClient}
|
||||
toolInput={{ monthlyBudget: 2500 }}
|
||||
toolResult={result}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
{
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "testApp",
|
||||
arguments: { monthlyBudget: 2500 },
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockBridgeEvents).toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByTestId("initialize-app"));
|
||||
|
|
@ -237,26 +206,50 @@ describe("AppRenderer", () => {
|
|||
(event) =>
|
||||
event.toolName === "testApp" && event.type === "sendToolResult",
|
||||
);
|
||||
expect(resultEvent?.payload).toEqual(mockResult);
|
||||
expect(resultEvent?.payload).toEqual(result);
|
||||
});
|
||||
|
||||
it("should send an app-consumable error result when tools/call fails", async () => {
|
||||
const requestMock = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("tool execution failed"));
|
||||
const mcpClient = {
|
||||
...mockMcpClient,
|
||||
request: requestMock,
|
||||
} as unknown as Client;
|
||||
|
||||
it("should not send tool result event when toolResult is null", async () => {
|
||||
render(
|
||||
<AppRenderer {...defaultProps} mcpClient={mcpClient} toolInput={{}} />,
|
||||
<AppRenderer
|
||||
{...defaultProps}
|
||||
toolInput={{ monthlyBudget: 2500 }}
|
||||
toolResult={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("initialize-app"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMock).toHaveBeenCalledTimes(1);
|
||||
const inputEvents = mockBridgeEvents.filter(
|
||||
(event) =>
|
||||
event.toolName === "testApp" && event.type === "sendToolInput",
|
||||
);
|
||||
expect(inputEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
const resultEvents = mockBridgeEvents.filter(
|
||||
(event) =>
|
||||
event.toolName === "testApp" && event.type === "sendToolResult",
|
||||
);
|
||||
expect(resultEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should normalize compatibility wrapper tool results before sending", async () => {
|
||||
const wrappedResult = {
|
||||
toolResult: {
|
||||
content: [{ type: "text", text: "Wrapped result payload" }],
|
||||
},
|
||||
} as CompatibilityCallToolResult;
|
||||
|
||||
render(
|
||||
<AppRenderer
|
||||
{...defaultProps}
|
||||
toolInput={{ monthlyBudget: 2500 }}
|
||||
toolResult={wrappedResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("initialize-app"));
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -264,95 +257,8 @@ describe("AppRenderer", () => {
|
|||
(event) =>
|
||||
event.toolName === "testApp" && event.type === "sendToolResult",
|
||||
);
|
||||
|
||||
expect(resultEvent?.payload).toEqual({
|
||||
content: [{ type: "text", text: "tool execution failed" }],
|
||||
isError: true,
|
||||
});
|
||||
expect(resultEvent).toBeTruthy();
|
||||
expect(resultEvent?.payload).toEqual(wrappedResult.toolResult);
|
||||
});
|
||||
});
|
||||
|
||||
it("should ignore stale tool results after switching app tools", async () => {
|
||||
const firstResult: CallToolResult = {
|
||||
content: [{ type: "text", text: "stale result" }],
|
||||
};
|
||||
const secondResult: CallToolResult = {
|
||||
content: [{ type: "text", text: "fresh result" }],
|
||||
};
|
||||
|
||||
const firstRequest = createDeferred<CallToolResult>();
|
||||
const secondRequest = createDeferred<CallToolResult>();
|
||||
const requestMock = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstRequest.promise)
|
||||
.mockReturnValueOnce(secondRequest.promise);
|
||||
|
||||
const mcpClient = {
|
||||
...mockMcpClient,
|
||||
request: requestMock,
|
||||
} as unknown as Client;
|
||||
|
||||
const { rerender } = render(
|
||||
<AppRenderer
|
||||
{...defaultProps}
|
||||
mcpClient={mcpClient}
|
||||
toolInput={{ month: "january" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const secondTool = {
|
||||
...mockTool,
|
||||
name: "budgetAllocatorApp",
|
||||
};
|
||||
|
||||
rerender(
|
||||
<AppRenderer
|
||||
{...defaultProps}
|
||||
mcpClient={mcpClient}
|
||||
tool={secondTool}
|
||||
toolInput={{ month: "february" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
secondRequest.resolve(secondResult);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("tool-result")).toHaveTextContent(
|
||||
"fresh result",
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("initialize-app"));
|
||||
|
||||
await waitFor(() => {
|
||||
const freshEvents = mockBridgeEvents.filter(
|
||||
(event) =>
|
||||
event.toolName === "budgetAllocatorApp" &&
|
||||
event.type === "sendToolResult",
|
||||
);
|
||||
expect(freshEvents).toHaveLength(1);
|
||||
expect(freshEvents[0].payload).toEqual(secondResult);
|
||||
});
|
||||
|
||||
firstRequest.resolve(firstResult);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const secondToolResultEvents = mockBridgeEvents.filter(
|
||||
(event) =>
|
||||
event.toolName === "budgetAllocatorApp" &&
|
||||
event.type === "sendToolResult",
|
||||
);
|
||||
|
||||
expect(secondToolResultEvents).toHaveLength(1);
|
||||
expect(secondToolResultEvents[0].payload).toEqual(secondResult);
|
||||
expect(screen.getByTestId("tool-result")).toHaveTextContent("fresh result");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||
import AppsTab from "../AppsTab";
|
||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
Tool,
|
||||
CompatibilityCallToolResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Tabs } from "../ui/tabs";
|
||||
|
||||
// Mock AppRenderer component
|
||||
|
|
@ -10,14 +19,19 @@ jest.mock("../AppRenderer", () => {
|
|||
return function MockAppRenderer({
|
||||
tool,
|
||||
toolInput,
|
||||
toolResult,
|
||||
}: {
|
||||
tool: Tool;
|
||||
toolInput?: Record<string, unknown>;
|
||||
toolResult?: unknown;
|
||||
}) {
|
||||
return (
|
||||
<div data-testid="app-renderer">
|
||||
<div>Tool: {tool.name}</div>
|
||||
<div data-testid="tool-input">{JSON.stringify(toolInput)}</div>
|
||||
<div data-testid="tool-result">
|
||||
{JSON.stringify(toolResult ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -50,6 +64,12 @@ describe("AppsTab", () => {
|
|||
const defaultProps = {
|
||||
tools: [],
|
||||
listTools: jest.fn(),
|
||||
callTool: jest.fn(
|
||||
async () =>
|
||||
({
|
||||
content: [],
|
||||
}) as CompatibilityCallToolResult,
|
||||
),
|
||||
error: null,
|
||||
mcpClient: null,
|
||||
};
|
||||
|
|
@ -131,7 +151,7 @@ describe("AppsTab", () => {
|
|||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", () => {
|
||||
it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", async () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "fieldsApp",
|
||||
inputSchema: {
|
||||
|
|
@ -142,9 +162,16 @@ describe("AppsTab", () => {
|
|||
},
|
||||
_meta: { ui: { resourceUri: "ui://fields" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
const mockCallTool = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
content: [],
|
||||
}) as CompatibilityCallToolResult,
|
||||
);
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
const appCard = screen.getByText("fieldsApp").closest("div");
|
||||
|
|
@ -159,12 +186,19 @@ describe("AppsTab", () => {
|
|||
const openAppButton = screen.getByRole("button", { name: /open app/i });
|
||||
fireEvent.click(openAppButton);
|
||||
|
||||
// AppRenderer should be rendered
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mockCallTool).toHaveBeenCalledWith("fieldsApp", {
|
||||
field1: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Tool: fieldsApp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close app renderer when close button is clicked", () => {
|
||||
it("should close app renderer when close button is clicked", async () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
|
@ -172,8 +206,9 @@ describe("AppsTab", () => {
|
|||
// Open the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close the app (Deselect tool)
|
||||
const closeButton = screen.getByRole("button", { name: /close app/i });
|
||||
|
|
@ -204,7 +239,7 @@ describe("AppsTab", () => {
|
|||
expect(screen.getByText("noDescriptionApp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reset selected tool when tools list changes and selected tool is removed", () => {
|
||||
it("should reset selected tool when tools list changes and selected tool is removed", async () => {
|
||||
const { rerender } = renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
|
@ -212,8 +247,9 @@ describe("AppsTab", () => {
|
|||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Update tools list to remove the selected tool
|
||||
rerender(
|
||||
|
|
@ -226,7 +262,7 @@ describe("AppsTab", () => {
|
|||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain selected tool when tools list updates but includes the same tool", () => {
|
||||
it("should maintain selected tool when tools list updates but includes the same tool", async () => {
|
||||
const { rerender } = renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
|
@ -234,8 +270,9 @@ describe("AppsTab", () => {
|
|||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Update tools list with the same tool
|
||||
rerender(
|
||||
|
|
@ -248,7 +285,7 @@ describe("AppsTab", () => {
|
|||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maximize and minimize the app window", () => {
|
||||
it("should maximize and minimize the app window", async () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
|
@ -256,12 +293,12 @@ describe("AppsTab", () => {
|
|||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
|
||||
// Now Maximize button should be visible
|
||||
expect(
|
||||
screen.getByRole("button", { name: /maximize/i }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /maximize/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initially, ListPane should be visible
|
||||
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
|
||||
|
|
@ -281,7 +318,7 @@ describe("AppsTab", () => {
|
|||
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should reset maximized state when app is closed", () => {
|
||||
it("should reset maximized state when app is closed", async () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
|
@ -289,7 +326,12 @@ describe("AppsTab", () => {
|
|||
// Select the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /maximize/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Maximize
|
||||
fireEvent.click(screen.getByRole("button", { name: /maximize/i }));
|
||||
|
|
@ -311,7 +353,7 @@ describe("AppsTab", () => {
|
|||
expect(appItem).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle empty resourceContentMap", () => {
|
||||
it("should handle empty resourceContentMap", async () => {
|
||||
renderAppsTab({
|
||||
tools: [mockAppTool],
|
||||
});
|
||||
|
|
@ -319,13 +361,13 @@ describe("AppsTab", () => {
|
|||
// Open the app
|
||||
const appCard = screen.getByText("weatherApp").closest("div");
|
||||
fireEvent.click(appCard!);
|
||||
// weatherApp has no properties in mockAppTool, so it should be open immediately
|
||||
|
||||
// AppRenderer should still be rendered
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle various input types and pass them to AppRenderer", () => {
|
||||
it("should handle various input types and pass them to AppRenderer", async () => {
|
||||
const toolWithComplexSchema: Tool = {
|
||||
name: "complexApp",
|
||||
inputSchema: {
|
||||
|
|
@ -359,6 +401,10 @@ describe("AppsTab", () => {
|
|||
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toolInput = JSON.parse(
|
||||
screen.getByTestId("tool-input").textContent || "{}",
|
||||
);
|
||||
|
|
@ -367,7 +413,7 @@ describe("AppsTab", () => {
|
|||
expect(toolInput.number).toBe(42);
|
||||
});
|
||||
|
||||
it("should handle nullable fields", () => {
|
||||
it("should handle nullable fields", async () => {
|
||||
const toolWithNullable: Tool = {
|
||||
name: "nullableApp",
|
||||
inputSchema: {
|
||||
|
|
@ -391,13 +437,17 @@ describe("AppsTab", () => {
|
|||
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toolInput = JSON.parse(
|
||||
screen.getByTestId("tool-input").textContent || "{}",
|
||||
);
|
||||
expect(toolInput.nullableField).toBe(null);
|
||||
});
|
||||
|
||||
it("should allow going back to input form from app renderer", () => {
|
||||
it("should allow going back to input form from app renderer", async () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "fieldsApp",
|
||||
inputSchema: {
|
||||
|
|
@ -416,13 +466,15 @@ describe("AppsTab", () => {
|
|||
fireEvent.click(screen.getByText("fieldsApp"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const backButton = screen.queryByRole("button", { name: /back to input/i });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should skip input form if tool has no input fields", () => {
|
||||
it("should skip input form if tool has no input fields", async () => {
|
||||
const toolNoFields: Tool = {
|
||||
name: "noFieldsApp",
|
||||
inputSchema: {
|
||||
|
|
@ -431,15 +483,27 @@ describe("AppsTab", () => {
|
|||
},
|
||||
_meta: { ui: { resourceUri: "ui://no-fields" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
const mockCallTool = jest.fn(
|
||||
async () =>
|
||||
({
|
||||
content: [],
|
||||
}) as CompatibilityCallToolResult,
|
||||
);
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolNoFields],
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("noFieldsApp"));
|
||||
|
||||
// Should see the renderer immediately
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mockCallTool).toHaveBeenCalledWith("noFieldsApp", {});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Tool: noFieldsApp")).toBeInTheDocument();
|
||||
|
||||
// Should NOT see the back button
|
||||
|
|
@ -448,7 +512,7 @@ describe("AppsTab", () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should allow going back to input form from app renderer if fields exist", () => {
|
||||
it("should allow going back to input form from app renderer if fields exist", async () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "fieldsApp",
|
||||
inputSchema: {
|
||||
|
|
@ -470,7 +534,9 @@ describe("AppsTab", () => {
|
|||
expect(screen.getByText("App Input")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back to input/i });
|
||||
fireEvent.click(backButton);
|
||||
|
|
@ -478,4 +544,229 @@ describe("AppsTab", () => {
|
|||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("App Input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should auto-render app from prefilled tools tab call without manual click", async () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "prefilledApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://prefilled" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
const onPrefilledToolCallConsumed = jest.fn();
|
||||
const prefilledResult: CompatibilityCallToolResult = {
|
||||
content: [{ type: "text", text: "weather result" }],
|
||||
};
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
prefilledToolCall: {
|
||||
id: 42,
|
||||
toolName: "prefilledApp",
|
||||
params: { city: "Lisbon" },
|
||||
result: prefilledResult,
|
||||
},
|
||||
onPrefilledToolCallConsumed,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tool: prefilledApp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toolInput = JSON.parse(
|
||||
screen.getByTestId("tool-input").textContent || "{}",
|
||||
);
|
||||
expect(toolInput).toEqual({ city: "Lisbon" });
|
||||
expect(screen.getByTestId("tool-result")).toHaveTextContent(
|
||||
"weather result",
|
||||
);
|
||||
expect(onPrefilledToolCallConsumed).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it("should not auto-render app when no prefilled tool call is provided", () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "manualApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://manual" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Select an app from the list to get started"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should preserve submitted params while opening app", async () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "paramsApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://params" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
let resolveCall: ((result: CompatibilityCallToolResult) => void) | null =
|
||||
null;
|
||||
const mockCallTool = jest.fn(
|
||||
() =>
|
||||
new Promise<CompatibilityCallToolResult>((resolve) => {
|
||||
resolveCall = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("paramsApp"));
|
||||
fireEvent.change(screen.getByLabelText("query"), {
|
||||
target: { value: "first value" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("query"), {
|
||||
target: { value: "second value" },
|
||||
});
|
||||
|
||||
expect(resolveCall).toBeTruthy();
|
||||
await act(async () => {
|
||||
resolveCall?.({
|
||||
content: [{ type: "text", text: "done" }],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toolInput = JSON.parse(
|
||||
screen.getByTestId("tool-input").textContent || "{}",
|
||||
);
|
||||
expect(toolInput.query).toBe("first value");
|
||||
});
|
||||
|
||||
it("should keep input view when opening fails and recover button state", async () => {
|
||||
const toolWithFields: Tool = {
|
||||
name: "failingApp",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string" },
|
||||
},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://failing" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
const mockCallTool = jest.fn(
|
||||
async () =>
|
||||
await Promise.reject<CompatibilityCallToolResult>(
|
||||
new Error("tool failed"),
|
||||
),
|
||||
);
|
||||
|
||||
renderAppsTab({
|
||||
tools: [toolWithFields],
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("failingApp"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCallTool).toHaveBeenCalledWith("failingApp", {
|
||||
query: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const openAppButton = screen.getByRole("button", { name: /open app/i });
|
||||
expect(openAppButton).toBeEnabled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("App Input")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should ignore stale results from older app launches", async () => {
|
||||
const appA: Tool = {
|
||||
name: "appA",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://app-a" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
const appB: Tool = {
|
||||
name: "appB",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
_meta: { ui: { resourceUri: "ui://app-b" } },
|
||||
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
|
||||
|
||||
const pendingCalls: {
|
||||
name: string;
|
||||
resolve: (result: CompatibilityCallToolResult) => void;
|
||||
}[] = [];
|
||||
const mockCallTool = jest.fn(
|
||||
(name: string) =>
|
||||
new Promise<CompatibilityCallToolResult>((resolve) => {
|
||||
pendingCalls.push({ name, resolve });
|
||||
}),
|
||||
);
|
||||
|
||||
renderAppsTab({
|
||||
tools: [appA, appB],
|
||||
callTool: mockCallTool,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("appA"));
|
||||
fireEvent.click(screen.getByText("appB"));
|
||||
|
||||
const appBCall = pendingCalls.find((call) => call.name === "appB");
|
||||
const appACall = pendingCalls.find((call) => call.name === "appA");
|
||||
expect(appBCall).toBeTruthy();
|
||||
expect(appACall).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
appBCall?.resolve({
|
||||
content: [{ type: "text", text: "result from appB" }],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tool: appB")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tool-result")).toHaveTextContent("appB");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
appACall?.resolve({
|
||||
content: [{ type: "text", text: "result from appA" }],
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tool: appB")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tool-result")).toHaveTextContent("appB");
|
||||
expect(screen.getByTestId("tool-result")).not.toHaveTextContent("appA");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
226
patches/@mcp-ui+client+6.0.0.patch
Normal file
226
patches/@mcp-ui+client+6.0.0.patch
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
diff --git a/node_modules/@mcp-ui/client/dist/index.mjs b/node_modules/@mcp-ui/client/dist/index.mjs
|
||||
index 38fbac3..fa81be4 100644
|
||||
--- a/node_modules/@mcp-ui/client/dist/index.mjs
|
||||
+++ b/node_modules/@mcp-ui/client/dist/index.mjs
|
||||
@@ -8417,23 +8417,40 @@ const $h = "io.modelcontextprotocol/ui", Ih = {
|
||||
}, jh = {
|
||||
[$h]: Ih
|
||||
}, kh = 1e4;
|
||||
-async function wh(e) {
|
||||
- const i = document.createElement("iframe");
|
||||
- i.style.width = "100%", i.style.height = "600px", i.style.border = "none", i.style.backgroundColor = "transparent", i.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
- const r = new Promise((a, t) => {
|
||||
- let n = !1;
|
||||
- const o = () => {
|
||||
- window.removeEventListener("message", u), i.removeEventListener("error", l);
|
||||
- }, s = setTimeout(() => {
|
||||
- n || (n = !0, o(), t(new Error("Timed out waiting for sandbox proxy iframe to be ready")));
|
||||
- }, kh), u = (d) => {
|
||||
- d.source === i.contentWindow && d.data && d.data.method === $f && (n || (n = !0, clearTimeout(s), o(), a()));
|
||||
+function Fh(e) {
|
||||
+ if (!e || typeof e != "object")
|
||||
+ return "";
|
||||
+ const i = [];
|
||||
+ return e.camera && i.push("camera"), e.microphone && i.push("microphone"), e.geolocation && i.push("geolocation"), e.clipboardWrite && i.push("clipboard-write"), i.join("; ");
|
||||
+}
|
||||
+function Jh(e) {
|
||||
+ if (!e || typeof e != "object")
|
||||
+ return {};
|
||||
+ const i = "_meta" in e && e._meta && typeof e._meta == "object" ? e._meta : "meta" in e && e.meta && typeof e.meta == "object" ? e.meta : void 0, r = i && typeof i.ui == "object" && i.ui ? i.ui : void 0;
|
||||
+ return {
|
||||
+ csp: r && typeof r.csp == "object" ? r.csp : void 0,
|
||||
+ permissions: r && typeof r.permissions == "object" ? r.permissions : void 0
|
||||
+ };
|
||||
+}
|
||||
+async function wh(e, i) {
|
||||
+ const r = document.createElement("iframe");
|
||||
+ r.style.width = "100%", r.style.height = "600px", r.style.border = "none", r.style.backgroundColor = "transparent", r.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
||||
+ const a = Fh(i);
|
||||
+ a && r.setAttribute("allow", a);
|
||||
+ const t = new Promise((n, o) => {
|
||||
+ let s = !1;
|
||||
+ const u = () => {
|
||||
+ window.removeEventListener("message", d), r.removeEventListener("error", l);
|
||||
+ }, c = setTimeout(() => {
|
||||
+ s || (s = !0, u(), o(new Error("Timed out waiting for sandbox proxy iframe to be ready")));
|
||||
+ }, kh), d = (v) => {
|
||||
+ v.source === r.contentWindow && v.data && v.data.method === $f && (s || (s = !0, clearTimeout(c), u(), n()));
|
||||
}, l = () => {
|
||||
- n || (n = !0, clearTimeout(s), o(), t(new Error("Failed to load sandbox proxy iframe")));
|
||||
+ s || (s = !0, clearTimeout(c), u(), o(new Error("Failed to load sandbox proxy iframe")));
|
||||
};
|
||||
- window.addEventListener("message", u), i.addEventListener("error", l);
|
||||
+ window.addEventListener("message", d), r.addEventListener("error", l);
|
||||
});
|
||||
- return i.src = e.href, { iframe: i, onReady: r };
|
||||
+ return r.src = e.href, { iframe: r, onReady: t };
|
||||
}
|
||||
async function Sh(e, i) {
|
||||
let r, a;
|
||||
@@ -8467,7 +8484,12 @@ async function xh(e, i) {
|
||||
t = atob(a.blob);
|
||||
else
|
||||
throw new Error("Unsupported UI resource content format: " + JSON.stringify(a));
|
||||
- return t;
|
||||
+ const o = Jh(a);
|
||||
+ return {
|
||||
+ html: t,
|
||||
+ csp: o.csp,
|
||||
+ permissions: o.permissions
|
||||
+ };
|
||||
}
|
||||
function Eh(e, i) {
|
||||
const r = new URL(e.href);
|
||||
@@ -8496,7 +8518,7 @@ const zh = (e) => {
|
||||
var ne, Q;
|
||||
try {
|
||||
$.current && ((ne = b.current) != null && ne.contains($.current)) && (b.current.removeChild($.current), $.current = null, I.current = null, N.current = null);
|
||||
- const { iframe: H, onReady: ge } = await wh(k);
|
||||
+ const { iframe: H, onReady: ge } = await wh(k, r.permissions);
|
||||
if (!R || ($.current = H, I.current = V, N.current = a, b.current && b.current.appendChild(H), await ge, !R) || (a.onsizechange = async (ae) => {
|
||||
var st;
|
||||
(st = J.current) == null || st.call(J, ae), $.current && (ae.width !== void 0 && ($.current.style.width = `${ae.width}px`), ae.height !== void 0 && ($.current.style.height = `${ae.height}px`));
|
||||
@@ -8518,21 +8540,22 @@ const zh = (e) => {
|
||||
})(), () => {
|
||||
R = !1;
|
||||
};
|
||||
- }, [r.url, r.csp, a]), q(() => {
|
||||
+ }, [r.url, r.csp, r.permissions, a]), q(() => {
|
||||
if (!c || !i || N.current !== a) return;
|
||||
(async () => {
|
||||
var V;
|
||||
try {
|
||||
console.log("[AppFrame] Sending HTML to sandbox"), await a.sendSandboxResourceReady({
|
||||
html: i,
|
||||
- csp: r.csp
|
||||
+ csp: r.csp,
|
||||
+ permissions: r.permissions
|
||||
});
|
||||
} catch (R) {
|
||||
const ve = R instanceof Error ? R : new Error(String(R));
|
||||
g(ve), (V = O.current) == null || V.call(O, ve);
|
||||
}
|
||||
})();
|
||||
- }, [c, i, a, r.csp]), q(() => {
|
||||
+ }, [c, i, a, r.csp, r.permissions]), q(() => {
|
||||
c && l && o && N.current === a && (console.log("[AppFrame] Sending tool input:", o), a.sendToolInput({ arguments: o }));
|
||||
}, [a, c, l, o]), q(() => {
|
||||
c && l && s && N.current === a && (console.log("[AppFrame] Sending tool result:", s), a.sendToolResult(s));
|
||||
@@ -8574,9 +8597,9 @@ const zh = (e) => {
|
||||
onListResourceTemplates: J,
|
||||
onReadResource: U,
|
||||
onListPrompts: O
|
||||
- } = e, [k, V] = Se(null), [R, ve] = Se(o ?? null), [ne, Q] = Se(null), H = F(f), ge = F(v), ae = F(g), st = F(b), Ue = F($), er = F(I), tr = F(N), ir = F(J), We = F(U), rr = F(O);
|
||||
+ } = e, [k, V] = Se(null), [R, ve] = Se(o ?? null), [ne, Q] = Se(null), [H, ge] = Se({ csp: void 0, permissions: void 0 }), ae = F(f), st = F(v), Ue = F(g), er = F(b), tr = F($), ir = F(I), We = F(N), rr = F(J), He = F(U), qe = F(O);
|
||||
q(() => {
|
||||
- H.current = f, ge.current = v, ae.current = g, st.current = b, Ue.current = $, er.current = I, tr.current = N, ir.current = J, We.current = U, rr.current = O;
|
||||
+ ae.current = f, st.current = v, Ue.current = g, er.current = b, tr.current = $, ir.current = I, We.current = N, rr.current = J, He.current = U, qe.current = O;
|
||||
}), Qa(
|
||||
i,
|
||||
() => ({
|
||||
@@ -8604,31 +8627,31 @@ const zh = (e) => {
|
||||
}
|
||||
);
|
||||
if (K.onmessage = async (G, A) => {
|
||||
- if (H.current)
|
||||
- return H.current(G, A);
|
||||
+ if (ae.current)
|
||||
+ return ae.current(G, A);
|
||||
throw new ba(ya.MethodNotFound, "Method not found");
|
||||
}, K.onopenlink = async (G, A) => {
|
||||
- if (ge.current)
|
||||
- return ge.current(G, A);
|
||||
+ if (st.current)
|
||||
+ return st.current(G, A);
|
||||
throw new ba(ya.MethodNotFound, "Method not found");
|
||||
}, K.onloggingmessage = (G) => {
|
||||
- ae.current && ae.current(G);
|
||||
- }, er.current && (K.oncalltool = (G, A) => er.current(G, A)), tr.current && (K.onlistresources = (G, A) => tr.current(G, A)), ir.current && (K.onlistresourcetemplates = (G, A) => ir.current(G, A)), We.current && (K.onreadresource = (G, A) => We.current(G, A)), rr.current && (K.onlistprompts = (G, A) => rr.current(G, A)), !ut) return;
|
||||
+ Ue.current && Ue.current(G);
|
||||
+ }, ir.current && (K.oncalltool = (G, A) => ir.current(G, A)), We.current && (K.onlistresources = (G, A) => We.current(G, A)), rr.current && (K.onlistresourcetemplates = (G, A) => rr.current(G, A)), He.current && (K.onreadresource = (G, A) => He.current(G, A)), qe.current && (K.onlistprompts = (G, A) => qe.current(G, A)), !ut) return;
|
||||
V(K);
|
||||
} catch (oe) {
|
||||
if (console.error("[AppRenderer] Error creating bridge:", oe), !ut) return;
|
||||
const K = oe instanceof Error ? oe : new Error(String(oe));
|
||||
- Q(K), (Oe = Ue.current) == null || Oe.call(Ue, K);
|
||||
+ Q(K), (Oe = tr.current) == null || Oe.call(tr, K);
|
||||
}
|
||||
})(), () => {
|
||||
ut = !1;
|
||||
};
|
||||
}, [r]), q(() => {
|
||||
if (o) {
|
||||
- ve(o);
|
||||
+ ve(o), ge({ csp: void 0, permissions: void 0 });
|
||||
return;
|
||||
}
|
||||
- const ut = !!r, ga = !!n && !!We.current;
|
||||
+ const ut = !!r, ga = !!n && !!He.current;
|
||||
if (!ut && !ga) {
|
||||
Q(
|
||||
new Error(
|
||||
@@ -8637,6 +8660,7 @@ const zh = (e) => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
+ ge({ csp: void 0, permissions: void 0 });
|
||||
let Oe = !0;
|
||||
return (async () => {
|
||||
var K, G;
|
||||
@@ -8657,10 +8681,12 @@ const zh = (e) => {
|
||||
if (!Oe) return;
|
||||
console.log(`[AppRenderer] Reading resource HTML from: ${A}`);
|
||||
let Ne;
|
||||
- if (r)
|
||||
- Ne = await xh(r, { uri: A });
|
||||
- else if (We.current) {
|
||||
- const Re = await We.current({ uri: A }, {});
|
||||
+ if (r) {
|
||||
+ const Re = await xh(r, { uri: A });
|
||||
+ if (!Oe) return;
|
||||
+ Ne = Re.html, ge({ csp: Re.csp, permissions: Re.permissions });
|
||||
+ } else if (He.current) {
|
||||
+ const Re = await He.current({ uri: A }, {});
|
||||
if (!Re.contents || Re.contents.length !== 1)
|
||||
throw new Error("Unsupported UI resource content length: " + ((K = Re.contents) == null ? void 0 : K.length));
|
||||
const _e = Re.contents[0], ha = (dm) => dm === va;
|
||||
@@ -8670,6 +8696,8 @@ const zh = (e) => {
|
||||
Ne = atob(_e.blob);
|
||||
else
|
||||
throw new Error("Unsupported UI resource content format: " + JSON.stringify(_e));
|
||||
+ const mt = Jh(_e);
|
||||
+ ge({ csp: mt.csp, permissions: mt.permissions });
|
||||
} else
|
||||
throw new Error("No way to read resource HTML");
|
||||
if (!Oe) return;
|
||||
@@ -8677,7 +8705,7 @@ const zh = (e) => {
|
||||
} catch (A) {
|
||||
if (!Oe) return;
|
||||
const Ne = A instanceof Error ? A : new Error(String(A));
|
||||
- Q(Ne), (G = Ue.current) == null || G.call(Ue, Ne);
|
||||
+ Q(Ne), (G = tr.current) == null || G.call(tr, Ne);
|
||||
}
|
||||
})(), () => {
|
||||
Oe = !1;
|
||||
@@ -8689,7 +8717,11 @@ const zh = (e) => {
|
||||
}, [k, l]), q(() => {
|
||||
k && d && k.sendToolCancelled({});
|
||||
}, [k, d]);
|
||||
- const lm = st.current;
|
||||
+ const lm = er.current, dm = {
|
||||
+ ...t,
|
||||
+ csp: H.csp ?? t.csp,
|
||||
+ permissions: H.permissions ?? t.permissions
|
||||
+ };
|
||||
return ne ? /* @__PURE__ */ bt("div", { style: { color: "red", padding: "1rem" }, children: [
|
||||
"Error: ",
|
||||
ne.message
|
||||
@@ -8697,7 +8729,7 @@ const zh = (e) => {
|
||||
zh,
|
||||
{
|
||||
html: R,
|
||||
- sandbox: t,
|
||||
+ sandbox: dm,
|
||||
appBridge: k,
|
||||
toolInput: s,
|
||||
toolResult: u,
|
||||
Loading…
Reference in a new issue