Merge branch 'main' into dev/eh/tool-meta-support
This commit is contained in:
commit
14657973bd
29 changed files with 2235 additions and 209 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "0.16.1",
|
"version": "0.16.2",
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
"devDependencies": {},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"spawn-rx": "^5.1.2"
|
"spawn-rx": "^5.1.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,21 @@ async function runWebClient(args: Args): Promise<void> {
|
||||||
abort.abort();
|
abort.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build arguments to pass to start.js
|
||||||
|
const startArgs: string[] = [];
|
||||||
|
|
||||||
|
// Pass environment variables
|
||||||
|
for (const [key, value] of Object.entries(args.envArgs)) {
|
||||||
|
startArgs.push("-e", `${key}=${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass command and args (using -- to separate them)
|
||||||
|
if (args.command) {
|
||||||
|
startArgs.push("--", args.command, ...args.args);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await spawnPromise("node", [inspectorClientPath], {
|
await spawnPromise("node", [inspectorClientPath, ...startArgs], {
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,10 @@ async function startProdServer(serverOptions) {
|
||||||
"node",
|
"node",
|
||||||
[
|
[
|
||||||
inspectorServerPath,
|
inspectorServerPath,
|
||||||
...(command ? [`--env`, command] : []),
|
...(command ? [`--command`, command] : []),
|
||||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
...(mcpServerArgs && mcpServerArgs.length > 0
|
||||||
|
? [`--args`, mcpServerArgs.join(" ")]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
|
|
|
||||||
16
client/e2e/startup-state.spec.ts
Normal file
16
client/e2e/startup-state.spec.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Adjust the URL if your dev server runs on a different port
|
||||||
|
const APP_URL = "http://localhost:6274/";
|
||||||
|
|
||||||
|
test.describe("Startup State", () => {
|
||||||
|
test("should not navigate to a tab when Inspector first opens", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(APP_URL);
|
||||||
|
|
||||||
|
// Check that there is no hash fragment in the URL
|
||||||
|
const url = page.url();
|
||||||
|
expect(url).not.toContain("#");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,4 +32,5 @@ module.exports = {
|
||||||
"/e2e/",
|
"/e2e/",
|
||||||
"\\.config\\.(js|ts|cjs|mjs)$",
|
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||||
],
|
],
|
||||||
|
randomize: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.16.1",
|
"version": "0.16.2",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -25,9 +25,8 @@
|
||||||
"cleanup:e2e": "node e2e/global-teardown.js"
|
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"ajv": "^6.12.6",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
|
@ -37,6 +36,7 @@
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"ajv": "^6.12.6",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.0.4",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ import {
|
||||||
initializeInspectorConfig,
|
initializeInspectorConfig,
|
||||||
saveInspectorConfig,
|
saveInspectorConfig,
|
||||||
} from "./utils/configUtils";
|
} from "./utils/configUtils";
|
||||||
|
import ElicitationTab, {
|
||||||
|
PendingElicitationRequest,
|
||||||
|
ElicitationResponse,
|
||||||
|
} from "./components/ElicitationTab";
|
||||||
|
|
||||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
|
|
||||||
|
|
@ -119,6 +123,14 @@ const App = () => {
|
||||||
return localStorage.getItem("lastHeaderName") || "";
|
return localStorage.getItem("lastHeaderName") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [oauthClientId, setOauthClientId] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastOauthClientId") || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [oauthScope, setOauthScope] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastOauthScope") || "";
|
||||||
|
});
|
||||||
|
|
||||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||||
Array<
|
Array<
|
||||||
PendingRequest & {
|
PendingRequest & {
|
||||||
|
|
@ -127,13 +139,19 @@ const App = () => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
>([]);
|
>([]);
|
||||||
|
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
|
||||||
|
Array<
|
||||||
|
PendingElicitationRequest & {
|
||||||
|
resolve: (response: ElicitationResponse) => void;
|
||||||
|
decline: (error: Error) => void;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>([]);
|
||||||
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
||||||
|
|
||||||
// Auth debugger state
|
|
||||||
const [authState, setAuthState] =
|
const [authState, setAuthState] =
|
||||||
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
||||||
|
|
||||||
// Helper function to update specific auth state properties
|
|
||||||
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||||
setAuthState((prev) => ({ ...prev, ...updates }));
|
setAuthState((prev) => ({ ...prev, ...updates }));
|
||||||
};
|
};
|
||||||
|
|
@ -161,6 +179,19 @@ const App = () => {
|
||||||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||||
const progressTokenRef = useRef(0);
|
const progressTokenRef = useRef(0);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
const initialTab = hash || "resources";
|
||||||
|
return initialTab;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTabRef = useRef<string>(activeTab);
|
||||||
|
const lastToolCallOriginTabRef = useRef<string>(activeTab);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentTabRef.current = activeTab;
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
||||||
const {
|
const {
|
||||||
width: sidebarWidth,
|
width: sidebarWidth,
|
||||||
|
|
@ -187,6 +218,8 @@ const App = () => {
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
headerName,
|
headerName,
|
||||||
|
oauthClientId,
|
||||||
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
|
|
@ -203,9 +236,64 @@ const App = () => {
|
||||||
{ id: nextRequestId.current++, request, resolve, reject },
|
{ id: nextRequestId.current++, request, resolve, reject },
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
onElicitationRequest: (request, resolve) => {
|
||||||
|
const currentTab = lastToolCallOriginTabRef.current;
|
||||||
|
|
||||||
|
setPendingElicitationRequests((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: nextRequestId.current++,
|
||||||
|
request: {
|
||||||
|
id: nextRequestId.current,
|
||||||
|
message: request.params.message,
|
||||||
|
requestedSchema: request.params.requestedSchema,
|
||||||
|
},
|
||||||
|
originatingTab: currentTab,
|
||||||
|
resolve,
|
||||||
|
decline: (error: Error) => {
|
||||||
|
console.error("Elicitation request rejected:", error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setActiveTab("elicitations");
|
||||||
|
window.location.hash = "elicitations";
|
||||||
|
},
|
||||||
getRoots: () => rootsRef.current,
|
getRoots: () => rootsRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverCapabilities) {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const validTabs = [
|
||||||
|
...(serverCapabilities?.resources ? ["resources"] : []),
|
||||||
|
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||||
|
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||||
|
"ping",
|
||||||
|
"sampling",
|
||||||
|
"elicitations",
|
||||||
|
"roots",
|
||||||
|
"auth",
|
||||||
|
];
|
||||||
|
|
||||||
|
const isValidTab = validTabs.includes(hash);
|
||||||
|
|
||||||
|
if (!isValidTab) {
|
||||||
|
const defaultTab = serverCapabilities?.resources
|
||||||
|
? "resources"
|
||||||
|
: serverCapabilities?.prompts
|
||||||
|
? "prompts"
|
||||||
|
: serverCapabilities?.tools
|
||||||
|
? "tools"
|
||||||
|
: "ping";
|
||||||
|
|
||||||
|
setActiveTab(defaultTab);
|
||||||
|
window.location.hash = defaultTab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [serverCapabilities]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastCommand", command);
|
localStorage.setItem("lastCommand", command);
|
||||||
}, [command]);
|
}, [command]);
|
||||||
|
|
@ -230,11 +318,18 @@ const App = () => {
|
||||||
localStorage.setItem("lastHeaderName", headerName);
|
localStorage.setItem("lastHeaderName", headerName);
|
||||||
}, [headerName]);
|
}, [headerName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastOauthClientId", oauthClientId);
|
||||||
|
}, [oauthClientId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastOauthScope", oauthScope);
|
||||||
|
}, [oauthScope]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
// Auto-connect to previously saved serverURL after OAuth callback
|
|
||||||
const onOAuthConnect = useCallback(
|
const onOAuthConnect = useCallback(
|
||||||
(serverUrl: string) => {
|
(serverUrl: string) => {
|
||||||
setSseUrl(serverUrl);
|
setSseUrl(serverUrl);
|
||||||
|
|
@ -244,7 +339,6 @@ const App = () => {
|
||||||
[connectMcpServer],
|
[connectMcpServer],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update OAuth debug state during debug callback
|
|
||||||
const onOAuthDebugConnect = useCallback(
|
const onOAuthDebugConnect = useCallback(
|
||||||
async ({
|
async ({
|
||||||
authorizationCode,
|
authorizationCode,
|
||||||
|
|
@ -265,7 +359,6 @@ const App = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restoredState && authorizationCode) {
|
if (restoredState && authorizationCode) {
|
||||||
// Restore the previous auth state and continue the OAuth flow
|
|
||||||
let currentState: AuthDebuggerState = {
|
let currentState: AuthDebuggerState = {
|
||||||
...restoredState,
|
...restoredState,
|
||||||
authorizationCode,
|
authorizationCode,
|
||||||
|
|
@ -276,12 +369,10 @@ const App = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a new state machine instance to continue the flow
|
|
||||||
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
|
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
|
||||||
currentState = { ...currentState, ...updates };
|
currentState = { ...currentState, ...updates };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Continue stepping through the OAuth flow from where we left off
|
|
||||||
while (
|
while (
|
||||||
currentState.oauthStep !== "complete" &&
|
currentState.oauthStep !== "complete" &&
|
||||||
currentState.oauthStep !== "authorization_code"
|
currentState.oauthStep !== "authorization_code"
|
||||||
|
|
@ -290,7 +381,6 @@ const App = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentState.oauthStep === "complete") {
|
if (currentState.oauthStep === "complete") {
|
||||||
// After the flow completes or reaches a user-input step, update the app state
|
|
||||||
updateAuthState({
|
updateAuthState({
|
||||||
...currentState,
|
...currentState,
|
||||||
statusMessage: {
|
statusMessage: {
|
||||||
|
|
@ -313,7 +403,6 @@ const App = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (authorizationCode) {
|
} else if (authorizationCode) {
|
||||||
// Fallback to the original behavior if no state was restored
|
|
||||||
updateAuthState({
|
updateAuthState({
|
||||||
authorizationCode,
|
authorizationCode,
|
||||||
oauthStep: "token_request",
|
oauthStep: "token_request",
|
||||||
|
|
@ -323,7 +412,6 @@ const App = () => {
|
||||||
[sseUrl],
|
[sseUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load OAuth tokens when sseUrl changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadOAuthTokens = async () => {
|
const loadOAuthTokens = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -377,10 +465,36 @@ const App = () => {
|
||||||
}, [roots]);
|
}, [roots]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.location.hash) {
|
if (mcpClient && !window.location.hash) {
|
||||||
window.location.hash = "resources";
|
const defaultTab = serverCapabilities?.resources
|
||||||
|
? "resources"
|
||||||
|
: serverCapabilities?.prompts
|
||||||
|
? "prompts"
|
||||||
|
: serverCapabilities?.tools
|
||||||
|
? "tools"
|
||||||
|
: "ping";
|
||||||
|
window.location.hash = defaultTab;
|
||||||
|
} else if (!mcpClient && window.location.hash) {
|
||||||
|
// Clear hash when disconnected - completely remove the fragment
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
window.location.pathname + window.location.search,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [mcpClient, serverCapabilities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleHashChange = () => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
if (hash && hash !== activeTab) {
|
||||||
|
setActiveTab(hash);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", handleHashChange);
|
||||||
|
return () => window.removeEventListener("hashchange", handleHashChange);
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
||||||
setPendingSampleRequests((prev) => {
|
setPendingSampleRequests((prev) => {
|
||||||
|
|
@ -398,6 +512,44 @@ const App = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResolveElicitation = (
|
||||||
|
id: number,
|
||||||
|
response: ElicitationResponse,
|
||||||
|
) => {
|
||||||
|
setPendingElicitationRequests((prev) => {
|
||||||
|
const request = prev.find((r) => r.id === id);
|
||||||
|
if (request) {
|
||||||
|
request.resolve(response);
|
||||||
|
|
||||||
|
if (request.originatingTab) {
|
||||||
|
const originatingTab = request.originatingTab;
|
||||||
|
|
||||||
|
const validTabs = [
|
||||||
|
...(serverCapabilities?.resources ? ["resources"] : []),
|
||||||
|
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||||
|
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||||
|
"ping",
|
||||||
|
"sampling",
|
||||||
|
"elicitations",
|
||||||
|
"roots",
|
||||||
|
"auth",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (validTabs.includes(originatingTab)) {
|
||||||
|
setActiveTab(originatingTab);
|
||||||
|
window.location.hash = originatingTab;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setActiveTab(originatingTab);
|
||||||
|
window.location.hash = originatingTab;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prev.filter((r) => r.id !== id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const clearError = (tabKey: keyof typeof errors) => {
|
const clearError = (tabKey: keyof typeof errors) => {
|
||||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||||
};
|
};
|
||||||
|
|
@ -455,7 +607,23 @@ const App = () => {
|
||||||
setNextResourceTemplateCursor(response.nextCursor);
|
setNextResourceTemplateCursor(response.nextCursor);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
||||||
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||||
|
|
||||||
|
const response = await sendMCPRequest(
|
||||||
|
{
|
||||||
|
method: "prompts/get" as const,
|
||||||
|
params: { name, arguments: args },
|
||||||
|
},
|
||||||
|
GetPromptResultSchema,
|
||||||
|
"prompts",
|
||||||
|
);
|
||||||
|
setPromptContent(JSON.stringify(response, null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
const readResource = async (uri: string) => {
|
const readResource = async (uri: string) => {
|
||||||
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||||
|
|
||||||
const response = await sendMCPRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
method: "resources/read" as const,
|
method: "resources/read" as const,
|
||||||
|
|
@ -517,18 +685,6 @@ const App = () => {
|
||||||
setNextPromptCursor(response.nextCursor);
|
setNextPromptCursor(response.nextCursor);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
|
||||||
const response = await sendMCPRequest(
|
|
||||||
{
|
|
||||||
method: "prompts/get" as const,
|
|
||||||
params: { name, arguments: args },
|
|
||||||
},
|
|
||||||
GetPromptResultSchema,
|
|
||||||
"prompts",
|
|
||||||
);
|
|
||||||
setPromptContent(JSON.stringify(response, null, 2));
|
|
||||||
};
|
|
||||||
|
|
||||||
const listTools = async () => {
|
const listTools = async () => {
|
||||||
const response = await sendMCPRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
|
|
@ -540,11 +696,12 @@ const App = () => {
|
||||||
);
|
);
|
||||||
setTools(response.tools);
|
setTools(response.tools);
|
||||||
setNextToolCursor(response.nextCursor);
|
setNextToolCursor(response.nextCursor);
|
||||||
// Cache output schemas for validation
|
|
||||||
cacheToolOutputSchemas(response.tools);
|
cacheToolOutputSchemas(response.tools);
|
||||||
};
|
};
|
||||||
|
|
||||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||||
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await sendMCPRequest(
|
const response = await sendMCPRequest(
|
||||||
{
|
{
|
||||||
|
|
@ -560,6 +717,7 @@ const App = () => {
|
||||||
CompatibilityCallToolResultSchema,
|
CompatibilityCallToolResultSchema,
|
||||||
"tools",
|
"tools",
|
||||||
);
|
);
|
||||||
|
|
||||||
setToolResult(response);
|
setToolResult(response);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const toolResult: CompatibilityCallToolResult = {
|
const toolResult: CompatibilityCallToolResult = {
|
||||||
|
|
@ -594,7 +752,6 @@ const App = () => {
|
||||||
setStdErrNotifications([]);
|
setStdErrNotifications([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper component for rendering the AuthDebugger
|
|
||||||
const AuthDebuggerWrapper = () => (
|
const AuthDebuggerWrapper = () => (
|
||||||
<TabsContent value="auth">
|
<TabsContent value="auth">
|
||||||
<AuthDebugger
|
<AuthDebugger
|
||||||
|
|
@ -606,7 +763,6 @@ const App = () => {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to render OAuth callback components
|
|
||||||
if (window.location.pathname === "/oauth/callback") {
|
if (window.location.pathname === "/oauth/callback") {
|
||||||
const OAuthCallback = React.lazy(
|
const OAuthCallback = React.lazy(
|
||||||
() => import("./components/OAuthCallback"),
|
() => import("./components/OAuthCallback"),
|
||||||
|
|
@ -658,6 +814,10 @@ const App = () => {
|
||||||
setBearerToken={setBearerToken}
|
setBearerToken={setBearerToken}
|
||||||
headerName={headerName}
|
headerName={headerName}
|
||||||
setHeaderName={setHeaderName}
|
setHeaderName={setHeaderName}
|
||||||
|
oauthClientId={oauthClientId}
|
||||||
|
setOauthClientId={setOauthClientId}
|
||||||
|
oauthScope={oauthScope}
|
||||||
|
setOauthScope={setOauthScope}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
onDisconnect={disconnectMcpServer}
|
onDisconnect={disconnectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
stdErrNotifications={stdErrNotifications}
|
||||||
|
|
@ -666,7 +826,6 @@ const App = () => {
|
||||||
loggingSupported={!!serverCapabilities?.logging || false}
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
clearStdErrNotifications={clearStdErrNotifications}
|
clearStdErrNotifications={clearStdErrNotifications}
|
||||||
/>
|
/>
|
||||||
{/* Drag handle for resizing sidebar */}
|
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleSidebarDragStart}
|
onMouseDown={handleSidebarDragStart}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -687,21 +846,12 @@ const App = () => {
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{mcpClient ? (
|
{mcpClient ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue={
|
value={activeTab}
|
||||||
Object.keys(serverCapabilities ?? {}).includes(
|
|
||||||
window.location.hash.slice(1),
|
|
||||||
)
|
|
||||||
? window.location.hash.slice(1)
|
|
||||||
: serverCapabilities?.resources
|
|
||||||
? "resources"
|
|
||||||
: serverCapabilities?.prompts
|
|
||||||
? "prompts"
|
|
||||||
: serverCapabilities?.tools
|
|
||||||
? "tools"
|
|
||||||
: "ping"
|
|
||||||
}
|
|
||||||
className="w-full p-4"
|
className="w-full p-4"
|
||||||
onValueChange={(value) => (window.location.hash = value)}
|
onValueChange={(value) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
window.location.hash = value;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TabsList className="mb-4 py-0">
|
<TabsList className="mb-4 py-0">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
|
|
@ -738,6 +888,15 @@ const App = () => {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="elicitations" className="relative">
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
Elicitations
|
||||||
|
{pendingElicitationRequests.length > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
|
||||||
|
{pendingElicitationRequests.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="roots">
|
<TabsTrigger value="roots">
|
||||||
<FolderTree className="w-4 h-4 mr-2" />
|
<FolderTree className="w-4 h-4 mr-2" />
|
||||||
Roots
|
Roots
|
||||||
|
|
@ -854,7 +1013,6 @@ const App = () => {
|
||||||
clearTools={() => {
|
clearTools={() => {
|
||||||
setTools([]);
|
setTools([]);
|
||||||
setNextToolCursor(undefined);
|
setNextToolCursor(undefined);
|
||||||
// Clear cached output schemas
|
|
||||||
cacheToolOutputSchemas([]);
|
cacheToolOutputSchemas([]);
|
||||||
}}
|
}}
|
||||||
callTool={async (name, params) => {
|
callTool={async (name, params) => {
|
||||||
|
|
@ -893,6 +1051,10 @@ const App = () => {
|
||||||
onApprove={handleApproveSampling}
|
onApprove={handleApproveSampling}
|
||||||
onReject={handleRejectSampling}
|
onReject={handleRejectSampling}
|
||||||
/>
|
/>
|
||||||
|
<ElicitationTab
|
||||||
|
pendingRequests={pendingElicitationRequests}
|
||||||
|
onResolve={handleResolveElicitation}
|
||||||
|
/>
|
||||||
<RootsTab
|
<RootsTab
|
||||||
roots={roots}
|
roots={roots}
|
||||||
setRoots={setRoots}
|
setRoots={setRoots}
|
||||||
|
|
|
||||||
159
client/src/__tests__/App.routing.test.tsx
Normal file
159
client/src/__tests__/App.routing.test.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { render, waitFor } from "@testing-library/react";
|
||||||
|
import App from "../App";
|
||||||
|
import { useConnection } from "../lib/hooks/useConnection";
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the config utils
|
||||||
|
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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Default connection state is disconnected
|
||||||
|
const disconnectedConnectionState = {
|
||||||
|
connectionStatus: "disconnected" as const,
|
||||||
|
serverCapabilities: null,
|
||||||
|
mcpClient: null,
|
||||||
|
requestHistory: [],
|
||||||
|
makeRequest: jest.fn(),
|
||||||
|
sendNotification: jest.fn(),
|
||||||
|
handleCompletion: jest.fn(),
|
||||||
|
completionsSupported: false,
|
||||||
|
connect: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connected state for tests that need an active connection
|
||||||
|
const connectedConnectionState = {
|
||||||
|
...disconnectedConnectionState,
|
||||||
|
connectionStatus: "connected" as const,
|
||||||
|
serverCapabilities: {},
|
||||||
|
mcpClient: {
|
||||||
|
request: jest.fn(),
|
||||||
|
notification: jest.fn(),
|
||||||
|
close: jest.fn(),
|
||||||
|
} as unknown as Client,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock required dependencies, but unrelated to routing.
|
||||||
|
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>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
|
||||||
|
|
||||||
|
// Use an empty module mock, so that mock state can be reset between tests.
|
||||||
|
jest.mock("../lib/hooks/useConnection", () => ({
|
||||||
|
useConnection: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("App - URL Fragment Routing", () => {
|
||||||
|
const mockUseConnection = jest.mocked(useConnection);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
|
||||||
|
// Inspector starts disconnected.
|
||||||
|
mockUseConnection.mockReturnValue(disconnectedConnectionState);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not set hash when starting disconnected", async () => {
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets default hash based on server capabilities priority", async () => {
|
||||||
|
// Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth
|
||||||
|
//
|
||||||
|
// Server capabilities determine the first three tabs; if none are present, falls back to Ping.
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
capabilities: { resources: { listChanged: true, subscribe: true } },
|
||||||
|
expected: "#resources",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: { prompts: { listChanged: true, subscribe: true } },
|
||||||
|
expected: "#prompts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: { tools: { listChanged: true, subscribe: true } },
|
||||||
|
expected: "#tools",
|
||||||
|
},
|
||||||
|
{ capabilities: {}, expected: "#ping" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { rerender } = render(<App />);
|
||||||
|
|
||||||
|
for (const { capabilities, expected } of testCases) {
|
||||||
|
window.location.hash = "";
|
||||||
|
mockUseConnection.mockReturnValue({
|
||||||
|
...connectedConnectionState,
|
||||||
|
serverCapabilities: capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears hash when disconnected", async () => {
|
||||||
|
// Start with a hash set (simulating a connection)
|
||||||
|
window.location.hash = "#resources";
|
||||||
|
|
||||||
|
// App starts disconnected (default mock)
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
// Should clear the hash when disconnected
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.hash).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -17,10 +17,10 @@ interface DynamicJsonFormProps {
|
||||||
|
|
||||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||||
if (supportedTypes.includes(schema.type)) return true;
|
if (schema.type && supportedTypes.includes(schema.type)) return true;
|
||||||
if (schema.type === "object") {
|
if (schema.type === "object") {
|
||||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
return Object.values(schema.properties ?? {}).every(
|
||||||
supportedTypes.includes(prop.type),
|
(prop) => prop.type && supportedTypes.includes(prop.type),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (schema.type === "array") {
|
if (schema.type === "array") {
|
||||||
|
|
@ -182,10 +182,89 @@ const DynamicJsonForm = ({
|
||||||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
parentSchema?.required?.includes(propertyName || "") ?? false;
|
||||||
|
|
||||||
switch (propSchema.type) {
|
switch (propSchema.type) {
|
||||||
case "string":
|
case "string": {
|
||||||
|
if (
|
||||||
|
propSchema.oneOf &&
|
||||||
|
propSchema.oneOf.every(
|
||||||
|
(option) =>
|
||||||
|
typeof option.const === "string" &&
|
||||||
|
typeof option.title === "string",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={(currentValue as string) ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (!val && !isRequired) {
|
||||||
|
handleFieldChange(path, undefined);
|
||||||
|
} else {
|
||||||
|
handleFieldChange(path, val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required={isRequired}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<option value="">Select an option...</option>
|
||||||
|
{propSchema.oneOf.map((option) => (
|
||||||
|
<option
|
||||||
|
key={option.const as string}
|
||||||
|
value={option.const as string}
|
||||||
|
>
|
||||||
|
{option.title as string}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propSchema.enum) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={(currentValue as string) ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (!val && !isRequired) {
|
||||||
|
handleFieldChange(path, undefined);
|
||||||
|
} else {
|
||||||
|
handleFieldChange(path, val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required={isRequired}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<option value="">Select an option...</option>
|
||||||
|
{propSchema.enum.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputType = "text";
|
||||||
|
switch (propSchema.format) {
|
||||||
|
case "email":
|
||||||
|
inputType = "email";
|
||||||
|
break;
|
||||||
|
case "uri":
|
||||||
|
inputType = "url";
|
||||||
|
break;
|
||||||
|
case "date":
|
||||||
|
inputType = "date";
|
||||||
|
break;
|
||||||
|
case "date-time":
|
||||||
|
inputType = "datetime-local";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
inputType = "text";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={inputType}
|
||||||
value={(currentValue as string) ?? ""}
|
value={(currentValue as string) ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
|
|
@ -194,8 +273,13 @@ const DynamicJsonForm = ({
|
||||||
}}
|
}}
|
||||||
placeholder={propSchema.description}
|
placeholder={propSchema.description}
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
|
minLength={propSchema.minLength}
|
||||||
|
maxLength={propSchema.maxLength}
|
||||||
|
pattern={propSchema.pattern}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -214,8 +298,11 @@ const DynamicJsonForm = ({
|
||||||
}}
|
}}
|
||||||
placeholder={propSchema.description}
|
placeholder={propSchema.description}
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
|
min={propSchema.minimum}
|
||||||
|
max={propSchema.maximum}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "integer":
|
case "integer":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -228,7 +315,6 @@ const DynamicJsonForm = ({
|
||||||
handleFieldChange(path, undefined);
|
handleFieldChange(path, undefined);
|
||||||
} else {
|
} else {
|
||||||
const num = Number(val);
|
const num = Number(val);
|
||||||
// Only update if it's a valid integer
|
|
||||||
if (!isNaN(num) && Number.isInteger(num)) {
|
if (!isNaN(num) && Number.isInteger(num)) {
|
||||||
handleFieldChange(path, num);
|
handleFieldChange(path, num);
|
||||||
}
|
}
|
||||||
|
|
@ -236,8 +322,11 @@ const DynamicJsonForm = ({
|
||||||
}}
|
}}
|
||||||
placeholder={propSchema.description}
|
placeholder={propSchema.description}
|
||||||
required={isRequired}
|
required={isRequired}
|
||||||
|
min={propSchema.minimum}
|
||||||
|
max={propSchema.maximum}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
173
client/src/components/ElicitationRequest.tsx
Normal file
173
client/src/components/ElicitationRequest.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import DynamicJsonForm from "./DynamicJsonForm";
|
||||||
|
import JsonView from "./JsonView";
|
||||||
|
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
|
||||||
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
|
import {
|
||||||
|
PendingElicitationRequest,
|
||||||
|
ElicitationResponse,
|
||||||
|
} from "./ElicitationTab";
|
||||||
|
import Ajv from "ajv";
|
||||||
|
|
||||||
|
export type ElicitationRequestProps = {
|
||||||
|
request: PendingElicitationRequest;
|
||||||
|
onResolve: (id: number, response: ElicitationResponse) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ElicitationRequest = ({
|
||||||
|
request,
|
||||||
|
onResolve,
|
||||||
|
}: ElicitationRequestProps) => {
|
||||||
|
const [formData, setFormData] = useState<JsonValue>({});
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const defaultValue = generateDefaultValue(request.request.requestedSchema);
|
||||||
|
setFormData(defaultValue);
|
||||||
|
setValidationError(null);
|
||||||
|
}, [request.request.requestedSchema]);
|
||||||
|
|
||||||
|
const validateEmailFormat = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateFormData = (
|
||||||
|
data: JsonValue,
|
||||||
|
schema: JsonSchemaType,
|
||||||
|
): boolean => {
|
||||||
|
if (
|
||||||
|
schema.type === "object" &&
|
||||||
|
schema.properties &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
data !== null
|
||||||
|
) {
|
||||||
|
const dataObj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (Array.isArray(schema.required)) {
|
||||||
|
for (const field of schema.required) {
|
||||||
|
const value = dataObj[field];
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
setValidationError(`Required field missing: ${field}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fieldName, fieldValue] of Object.entries(dataObj)) {
|
||||||
|
const fieldSchema = schema.properties[fieldName];
|
||||||
|
if (
|
||||||
|
fieldSchema &&
|
||||||
|
fieldSchema.format === "email" &&
|
||||||
|
typeof fieldValue === "string"
|
||||||
|
) {
|
||||||
|
if (!validateEmailFormat(fieldValue)) {
|
||||||
|
setValidationError(`Invalid email format: ${fieldName}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccept = () => {
|
||||||
|
try {
|
||||||
|
if (!validateFormData(formData, request.request.requestedSchema)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ajv = new Ajv();
|
||||||
|
const validate = ajv.compile(request.request.requestedSchema);
|
||||||
|
const isValid = validate(formData);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
const errorMessage = ajv.errorsText(validate.errors);
|
||||||
|
setValidationError(errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onResolve(request.id, {
|
||||||
|
action: "accept",
|
||||||
|
content: formData as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setValidationError(
|
||||||
|
error instanceof Error ? error.message : "Validation failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecline = () => {
|
||||||
|
onResolve(request.id, { action: "decline" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
onResolve(request.id, { action: "cancel" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemaTitle =
|
||||||
|
request.request.requestedSchema.title || "Information Request";
|
||||||
|
const schemaDescription = request.request.requestedSchema.description;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="elicitation-request"
|
||||||
|
className="flex gap-4 p-4 border rounded-lg space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-semibold">{schemaTitle}</h4>
|
||||||
|
<p className="text-sm">{request.request.message}</p>
|
||||||
|
{schemaDescription && (
|
||||||
|
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
|
||||||
|
<JsonView
|
||||||
|
data={JSON.stringify(request.request.requestedSchema, null, 2)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium">Response Form</h4>
|
||||||
|
<DynamicJsonForm
|
||||||
|
schema={request.request.requestedSchema}
|
||||||
|
value={formData}
|
||||||
|
onChange={(newValue: JsonValue) => {
|
||||||
|
setFormData(newValue);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
<strong>Validation Error:</strong> {validationError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2 mt-1">
|
||||||
|
<Button type="button" onClick={handleAccept}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={handleDecline}>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElicitationRequest;
|
||||||
56
client/src/components/ElicitationTab.tsx
Normal file
56
client/src/components/ElicitationTab.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
|
import ElicitationRequest from "./ElicitationRequest";
|
||||||
|
|
||||||
|
export interface ElicitationRequestData {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
requestedSchema: JsonSchemaType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElicitationResponse {
|
||||||
|
action: "accept" | "decline" | "cancel";
|
||||||
|
content?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingElicitationRequest = {
|
||||||
|
id: number;
|
||||||
|
request: ElicitationRequestData;
|
||||||
|
originatingTab?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
pendingRequests: PendingElicitationRequest[];
|
||||||
|
onResolve: (id: number, response: ElicitationResponse) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
|
||||||
|
return (
|
||||||
|
<TabsContent value="elicitations">
|
||||||
|
<div className="h-96">
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
When the server requests information from the user, requests will
|
||||||
|
appear here for response.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||||
|
{pendingRequests.map((request) => (
|
||||||
|
<ElicitationRequest
|
||||||
|
key={request.id}
|
||||||
|
request={request}
|
||||||
|
onResolve={onResolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{pendingRequests.length === 0 && (
|
||||||
|
<p className="text-gray-500">No pending requests</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElicitationTab;
|
||||||
|
|
@ -48,6 +48,7 @@ const PromptsTab = ({
|
||||||
ref: PromptReference | ResourceReference,
|
ref: PromptReference | ResourceReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
context?: Record<string, string>,
|
||||||
) => Promise<string[]>;
|
) => Promise<string[]>;
|
||||||
completionsSupported: boolean;
|
completionsSupported: boolean;
|
||||||
promptContent: string;
|
promptContent: string;
|
||||||
|
|
@ -73,6 +74,7 @@ const PromptsTab = ({
|
||||||
},
|
},
|
||||||
argName,
|
argName,
|
||||||
value,
|
value,
|
||||||
|
promptArgs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const ResourcesTab = ({
|
||||||
ref: ResourceReference | PromptReference,
|
ref: ResourceReference | PromptReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
context?: Record<string, string>,
|
||||||
) => Promise<string[]>;
|
) => Promise<string[]>;
|
||||||
completionsSupported: boolean;
|
completionsSupported: boolean;
|
||||||
resourceContent: string;
|
resourceContent: string;
|
||||||
|
|
@ -94,6 +95,7 @@ const ResourcesTab = ({
|
||||||
},
|
},
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
|
templateValues,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ interface SidebarProps {
|
||||||
setBearerToken: (token: string) => void;
|
setBearerToken: (token: string) => void;
|
||||||
headerName?: string;
|
headerName?: string;
|
||||||
setHeaderName?: (name: string) => void;
|
setHeaderName?: (name: string) => void;
|
||||||
|
oauthClientId: string;
|
||||||
|
setOauthClientId: (id: string) => void;
|
||||||
|
oauthScope: string;
|
||||||
|
setOauthScope: (scope: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
stdErrNotifications: StdErrNotification[];
|
||||||
|
|
@ -83,6 +87,10 @@ const Sidebar = ({
|
||||||
setBearerToken,
|
setBearerToken,
|
||||||
headerName,
|
headerName,
|
||||||
setHeaderName,
|
setHeaderName,
|
||||||
|
oauthClientId,
|
||||||
|
setOauthClientId,
|
||||||
|
oauthScope,
|
||||||
|
setOauthScope,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
stdErrNotifications,
|
stdErrNotifications,
|
||||||
|
|
@ -95,7 +103,7 @@ const Sidebar = ({
|
||||||
}: SidebarProps) => {
|
}: SidebarProps) => {
|
||||||
const [theme, setTheme] = useTheme();
|
const [theme, setTheme] = useTheme();
|
||||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||||
const [showBearerToken, setShowBearerToken] = useState(false);
|
const [showAuthConfig, setShowAuthConfig] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
|
||||||
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
|
||||||
|
|
@ -308,51 +316,6 @@ const Sidebar = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
|
||||||
className="flex items-center w-full"
|
|
||||||
data-testid="auth-button"
|
|
||||||
aria-expanded={showBearerToken}
|
|
||||||
>
|
|
||||||
{showBearerToken ? (
|
|
||||||
<ChevronDown className="w-4 h-4 mr-2" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Authentication
|
|
||||||
</Button>
|
|
||||||
{showBearerToken && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium">Header Name</label>
|
|
||||||
<Input
|
|
||||||
placeholder="Authorization"
|
|
||||||
onChange={(e) =>
|
|
||||||
setHeaderName && setHeaderName(e.target.value)
|
|
||||||
}
|
|
||||||
data-testid="header-input"
|
|
||||||
className="font-mono"
|
|
||||||
value={headerName}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
className="text-sm font-medium"
|
|
||||||
htmlFor="bearer-token-input"
|
|
||||||
>
|
|
||||||
Bearer Token
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="bearer-token-input"
|
|
||||||
placeholder="Bearer Token"
|
|
||||||
value={bearerToken}
|
|
||||||
onChange={(e) => setBearerToken(e.target.value)}
|
|
||||||
data-testid="bearer-token-input"
|
|
||||||
className="font-mono"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -521,6 +484,94 @@ const Sidebar = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowAuthConfig(!showAuthConfig)}
|
||||||
|
className="flex items-center w-full"
|
||||||
|
data-testid="auth-button"
|
||||||
|
aria-expanded={showAuthConfig}
|
||||||
|
>
|
||||||
|
{showAuthConfig ? (
|
||||||
|
<ChevronDown className="w-4 h-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Authentication
|
||||||
|
</Button>
|
||||||
|
{showAuthConfig && (
|
||||||
|
<>
|
||||||
|
{/* Bearer Token Section */}
|
||||||
|
<div className="space-y-2 p-3 rounded border">
|
||||||
|
<h4 className="text-sm font-semibold flex items-center">
|
||||||
|
API Token Authentication
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Header Name</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Authorization"
|
||||||
|
onChange={(e) =>
|
||||||
|
setHeaderName && setHeaderName(e.target.value)
|
||||||
|
}
|
||||||
|
data-testid="header-input"
|
||||||
|
className="font-mono"
|
||||||
|
value={headerName}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium"
|
||||||
|
htmlFor="bearer-token-input"
|
||||||
|
>
|
||||||
|
Bearer Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="bearer-token-input"
|
||||||
|
placeholder="Bearer Token"
|
||||||
|
value={bearerToken}
|
||||||
|
onChange={(e) => setBearerToken(e.target.value)}
|
||||||
|
data-testid="bearer-token-input"
|
||||||
|
className="font-mono"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{transportType !== "stdio" && (
|
||||||
|
// OAuth Configuration
|
||||||
|
<div className="space-y-2 p-3 rounded border">
|
||||||
|
<h4 className="text-sm font-semibold flex items-center">
|
||||||
|
OAuth 2.0 Flow
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Client ID</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Client ID"
|
||||||
|
onChange={(e) => setOauthClientId(e.target.value)}
|
||||||
|
value={oauthClientId}
|
||||||
|
data-testid="oauth-client-id-input"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
Redirect URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
placeholder="Redirect URL"
|
||||||
|
value={window.location.origin + "/oauth/callback"}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<label className="text-sm font-medium">Scope</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Scope (space-separated)"
|
||||||
|
onChange={(e) => setOauthScope(e.target.value)}
|
||||||
|
value={oauthScope}
|
||||||
|
data-testid="oauth-scope-input"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* Configuration */}
|
{/* Configuration */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
import DynamicJsonForm from "../DynamicJsonForm";
|
import DynamicJsonForm from "../DynamicJsonForm";
|
||||||
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
|
|
@ -35,6 +36,188 @@ describe("DynamicJsonForm String Fields", () => {
|
||||||
expect(input).toHaveProperty("type", "text");
|
expect(input).toHaveProperty("type", "text");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Format Support", () => {
|
||||||
|
it("should render email input for email format", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
format: "email",
|
||||||
|
description: "Email address",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveProperty("type", "email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render url input for uri format", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
format: "uri",
|
||||||
|
description: "Website URL",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveProperty("type", "url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render date input for date format", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
format: "date",
|
||||||
|
description: "Birth date",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue("");
|
||||||
|
expect(input).toHaveProperty("type", "date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render datetime-local input for date-time format", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
description: "Event datetime",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue("");
|
||||||
|
expect(input).toHaveProperty("type", "datetime-local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Enum Support", () => {
|
||||||
|
it("should render select dropdown for enum fields", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
enum: ["option1", "option2", "option3"],
|
||||||
|
description: "Select an option",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
expect(select.tagName).toBe("SELECT");
|
||||||
|
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
expect(options).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use oneOf with const and title for labeled options", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
oneOf: [
|
||||||
|
{ const: "val1", title: "Label 1" },
|
||||||
|
{ const: "val2", title: "Label 2" },
|
||||||
|
],
|
||||||
|
description: "Select with labels",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
expect(options[1]).toHaveProperty("textContent", "Label 1");
|
||||||
|
expect(options[2]).toHaveProperty("textContent", "Label 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onChange with selected oneOf value", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
oneOf: [
|
||||||
|
{ const: "option1", title: "Option 1" },
|
||||||
|
{ const: "option2", title: "Option 2" },
|
||||||
|
],
|
||||||
|
description: "Select an option",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
fireEvent.change(select, { target: { value: "option1" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith("option1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onChange with selected enum value", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
enum: ["option1", "option2"],
|
||||||
|
description: "Select an option",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
fireEvent.change(select, { target: { value: "option1" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith("option1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render JSON Schema spec compliant oneOf with const for labeled enums", () => {
|
||||||
|
// Example from JSON Schema spec: labeled enums using oneOf with const
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
title: "Traffic Light",
|
||||||
|
description: "Select a traffic light color",
|
||||||
|
oneOf: [
|
||||||
|
{ const: "red", title: "Stop" },
|
||||||
|
{ const: "amber", title: "Caution" },
|
||||||
|
{ const: "green", title: "Go" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
// Should render as a select dropdown
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
expect(select.tagName).toBe("SELECT");
|
||||||
|
|
||||||
|
// Should have options with proper labels
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
expect(options).toHaveLength(4); // 3 options + 1 default "Select an option..."
|
||||||
|
|
||||||
|
expect(options[0]).toHaveProperty("textContent", "Select an option...");
|
||||||
|
expect(options[1]).toHaveProperty("textContent", "Stop");
|
||||||
|
expect(options[2]).toHaveProperty("textContent", "Caution");
|
||||||
|
expect(options[3]).toHaveProperty("textContent", "Go");
|
||||||
|
|
||||||
|
// Should have proper values
|
||||||
|
expect(options[1]).toHaveProperty("value", "red");
|
||||||
|
expect(options[2]).toHaveProperty("value", "amber");
|
||||||
|
expect(options[3]).toHaveProperty("value", "green");
|
||||||
|
|
||||||
|
// Test onChange behavior
|
||||||
|
fireEvent.change(select, { target: { value: "amber" } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith("amber");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Validation Attributes", () => {
|
||||||
|
it("should apply minLength and maxLength", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 10,
|
||||||
|
description: "Username",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveProperty("minLength", 3);
|
||||||
|
expect(input).toHaveProperty("maxLength", 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply pattern validation", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "string",
|
||||||
|
pattern: "^[A-Za-z]+$",
|
||||||
|
description: "Letters only",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
expect(input).toHaveProperty("pattern", "^[A-Za-z]+$");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DynamicJsonForm Integer Fields", () => {
|
describe("DynamicJsonForm Integer Fields", () => {
|
||||||
|
|
@ -81,6 +264,38 @@ describe("DynamicJsonForm Integer Fields", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Validation", () => {
|
||||||
|
it("should apply min and max constraints", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 100,
|
||||||
|
description: "Age",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
expect(input).toHaveProperty("min", "0");
|
||||||
|
expect(input).toHaveProperty("max", "100");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only accept integer values", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "integer",
|
||||||
|
description: "Count",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
fireEvent.change(input, { target: { value: "3.14" } });
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
describe("Edge Cases", () => {
|
||||||
it("should handle non-numeric input by not calling onChange", () => {
|
it("should handle non-numeric input by not calling onChange", () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
|
|
@ -94,6 +309,233 @@ describe("DynamicJsonForm Integer Fields", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DynamicJsonForm Number Fields", () => {
|
||||||
|
describe("Validation", () => {
|
||||||
|
it("should apply min and max constraints", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "number",
|
||||||
|
minimum: 0.5,
|
||||||
|
maximum: 99.9,
|
||||||
|
description: "Score",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={0} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
expect(input).toHaveProperty("min", "0.5");
|
||||||
|
expect(input).toHaveProperty("max", "99.9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept decimal values", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "number",
|
||||||
|
description: "Temperature",
|
||||||
|
};
|
||||||
|
render(<DynamicJsonForm schema={schema} value={0} onChange={onChange} />);
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
fireEvent.change(input, { target: { value: "98.6" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(98.6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DynamicJsonForm Boolean Fields", () => {
|
||||||
|
describe("Basic Operations", () => {
|
||||||
|
it("should render checkbox for boolean type", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Enable notifications",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
expect(checkbox).toHaveProperty("type", "checkbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onChange with boolean value", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Accept terms",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={false} onChange={onChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render boolean field with description", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Enable dark mode",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={false} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole("checkbox");
|
||||||
|
expect(checkbox).toHaveProperty("checked", false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DynamicJsonForm Object Fields", () => {
|
||||||
|
describe("Property Rendering", () => {
|
||||||
|
it("should render input fields for object properties", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
title: "Full Name",
|
||||||
|
description: "Your full name",
|
||||||
|
},
|
||||||
|
age: {
|
||||||
|
type: "integer",
|
||||||
|
title: "Age",
|
||||||
|
description: "Your age in years",
|
||||||
|
minimum: 18,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: "string",
|
||||||
|
format: "email",
|
||||||
|
title: "Email",
|
||||||
|
description: "Your email address",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textInputs = screen.getAllByRole("textbox");
|
||||||
|
const numberInput = screen.getByRole("spinbutton");
|
||||||
|
|
||||||
|
expect(textInputs).toHaveLength(2);
|
||||||
|
expect(textInputs[0]).toHaveProperty("type", "text");
|
||||||
|
expect(textInputs[1]).toHaveProperty("type", "email");
|
||||||
|
expect(numberInput).toHaveProperty("type", "number");
|
||||||
|
expect(numberInput).toHaveProperty("min", "18");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle object field changes correctly", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
username: {
|
||||||
|
type: "string",
|
||||||
|
description: "Your username",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={{}} onChange={onChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(input, { target: { value: "testuser" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ username: "testuser" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle nested object values correctly", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
title: "Name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm
|
||||||
|
schema={schema}
|
||||||
|
value={{ name: "John" }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue("John");
|
||||||
|
fireEvent.change(input, { target: { value: "Jane" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ name: "Jane" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Required Fields", () => {
|
||||||
|
it("should mark required fields with required attribute", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
title: "Name",
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: "string",
|
||||||
|
title: "Email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputs = screen.getAllByRole("textbox");
|
||||||
|
const nameInput = inputs[0];
|
||||||
|
const emailInput = inputs[1];
|
||||||
|
|
||||||
|
expect(nameInput).toHaveProperty("required", true);
|
||||||
|
expect(emailInput).toHaveProperty("required", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark required fields with required attribute", () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
title: "Name",
|
||||||
|
},
|
||||||
|
optional: {
|
||||||
|
type: "string",
|
||||||
|
title: "Optional",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<DynamicJsonForm schema={schema} value={{}} onChange={jest.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameLabel = screen.getByText("name");
|
||||||
|
const optionalLabel = screen.getByText("optional");
|
||||||
|
|
||||||
|
const nameInput = nameLabel.closest("div")?.querySelector("input");
|
||||||
|
const optionalInput = optionalLabel
|
||||||
|
.closest("div")
|
||||||
|
?.querySelector("input");
|
||||||
|
|
||||||
|
expect(nameInput).toHaveProperty("required", true);
|
||||||
|
expect(optionalInput).toHaveProperty("required", false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DynamicJsonForm Complex Fields", () => {
|
describe("DynamicJsonForm Complex Fields", () => {
|
||||||
const renderForm = (props = {}) => {
|
const renderForm = (props = {}) => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
|
|
||||||
202
client/src/components/__tests__/ElicitationRequest.test.tsx
Normal file
202
client/src/components/__tests__/ElicitationRequest.test.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { describe, it, jest, beforeEach, afterEach } from "@jest/globals";
|
||||||
|
import ElicitationRequest from "../ElicitationRequest";
|
||||||
|
import { PendingElicitationRequest } from "../ElicitationTab";
|
||||||
|
|
||||||
|
jest.mock("../DynamicJsonForm", () => {
|
||||||
|
return function MockDynamicJsonForm({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
onChange: (value: unknown) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div data-testid="dynamic-json-form">
|
||||||
|
<input
|
||||||
|
data-testid="form-input"
|
||||||
|
value={
|
||||||
|
typeof value === "object" && value !== null
|
||||||
|
? JSON.stringify(value)
|
||||||
|
: String(value || "")
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.target.value);
|
||||||
|
onChange(parsed);
|
||||||
|
} catch {
|
||||||
|
onChange(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ElicitationRequest", () => {
|
||||||
|
const mockOnResolve = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockRequest = (
|
||||||
|
overrides: Partial<PendingElicitationRequest> = {},
|
||||||
|
): PendingElicitationRequest => ({
|
||||||
|
id: 1,
|
||||||
|
request: {
|
||||||
|
id: 1,
|
||||||
|
message: "Please provide your information",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Your name" },
|
||||||
|
email: { type: "string", format: "email", description: "Your email" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderElicitationRequest = (
|
||||||
|
request: PendingElicitationRequest = createMockRequest(),
|
||||||
|
) => {
|
||||||
|
return render(
|
||||||
|
<ElicitationRequest request={request} onResolve={mockOnResolve} />,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Rendering", () => {
|
||||||
|
it("should render the component", () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
expect(screen.getByTestId("elicitation-request")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display request message", () => {
|
||||||
|
const message = "Please provide your GitHub username";
|
||||||
|
renderElicitationRequest(
|
||||||
|
createMockRequest({
|
||||||
|
request: {
|
||||||
|
id: 1,
|
||||||
|
message,
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(message)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render all three action buttons", () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /cancel/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /decline/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /submit/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render DynamicJsonForm component", () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
expect(screen.getByTestId("dynamic-json-form")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User Interactions", () => {
|
||||||
|
it("should call onResolve with accept action when Submit button is clicked", async () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
|
||||||
|
const input = screen.getByTestId("form-input");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, {
|
||||||
|
target: {
|
||||||
|
value: '{"name": "John Doe", "email": "john@example.com"}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnResolve).toHaveBeenCalledWith(1, {
|
||||||
|
action: "accept",
|
||||||
|
content: { name: "John Doe", email: "john@example.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onResolve with decline action when Decline button is clicked", async () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /decline/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "decline" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onResolve with cancel action when Cancel button is clicked", async () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnResolve).toHaveBeenCalledWith(1, { action: "cancel" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Form Validation", () => {
|
||||||
|
it("should show validation error for missing required fields", async () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Required field missing: name/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mockOnResolve).not.toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ action: "accept" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show validation error for invalid email format", async () => {
|
||||||
|
renderElicitationRequest();
|
||||||
|
|
||||||
|
const input = screen.getByTestId("form-input");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, {
|
||||||
|
target: { value: '{"name": "John", "email": "invalid-email"}' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /submit/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Invalid email format: email/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mockOnResolve).not.toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ action: "accept" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
client/src/components/__tests__/ElicitationTab.test.tsx
Normal file
50
client/src/components/__tests__/ElicitationTab.test.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import ElicitationTab, { PendingElicitationRequest } from "../ElicitationTab";
|
||||||
|
|
||||||
|
describe("Elicitation tab", () => {
|
||||||
|
const mockOnResolve = jest.fn();
|
||||||
|
|
||||||
|
const renderElicitationTab = (pendingRequests: PendingElicitationRequest[]) =>
|
||||||
|
render(
|
||||||
|
<Tabs defaultValue="elicitations">
|
||||||
|
<ElicitationTab
|
||||||
|
pendingRequests={pendingRequests}
|
||||||
|
onResolve={mockOnResolve}
|
||||||
|
/>
|
||||||
|
</Tabs>,
|
||||||
|
);
|
||||||
|
|
||||||
|
it("should render 'No pending requests' when there are no pending requests", () => {
|
||||||
|
renderElicitationTab([]);
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"When the server requests information from the user, requests will appear here for response.",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(screen.findByText("No pending requests")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the correct number of requests", () => {
|
||||||
|
renderElicitationTab(
|
||||||
|
Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
request: {
|
||||||
|
id: i,
|
||||||
|
message: `Please provide information ${i}`,
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Your name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
expect(screen.getAllByTestId("elicitation-request").length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -42,6 +42,10 @@ describe("Sidebar Environment Variables", () => {
|
||||||
setArgs: jest.fn(),
|
setArgs: jest.fn(),
|
||||||
sseUrl: "",
|
sseUrl: "",
|
||||||
setSseUrl: jest.fn(),
|
setSseUrl: jest.fn(),
|
||||||
|
oauthClientId: "",
|
||||||
|
setOauthClientId: jest.fn(),
|
||||||
|
oauthScope: "",
|
||||||
|
setOauthScope: jest.fn(),
|
||||||
env: {},
|
env: {},
|
||||||
setEnv: jest.fn(),
|
setEnv: jest.fn(),
|
||||||
bearerToken: "",
|
bearerToken: "",
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,64 @@ import {
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||||
|
|
||||||
|
export const getClientInformationFromSessionStorage = async ({
|
||||||
|
serverUrl,
|
||||||
|
isPreregistered,
|
||||||
|
}: {
|
||||||
|
serverUrl: string;
|
||||||
|
isPreregistered?: boolean;
|
||||||
|
}) => {
|
||||||
|
const key = getServerSpecificKey(
|
||||||
|
isPreregistered
|
||||||
|
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
|
||||||
|
: SESSION_KEYS.CLIENT_INFORMATION,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = sessionStorage.getItem(key);
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveClientInformationToSessionStorage = ({
|
||||||
|
serverUrl,
|
||||||
|
clientInformation,
|
||||||
|
isPreregistered,
|
||||||
|
}: {
|
||||||
|
serverUrl: string;
|
||||||
|
clientInformation: OAuthClientInformation;
|
||||||
|
isPreregistered?: boolean;
|
||||||
|
}) => {
|
||||||
|
const key = getServerSpecificKey(
|
||||||
|
isPreregistered
|
||||||
|
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
|
||||||
|
: SESSION_KEYS.CLIENT_INFORMATION,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(clientInformation));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearClientInformationFromSessionStorage = ({
|
||||||
|
serverUrl,
|
||||||
|
isPreregistered,
|
||||||
|
}: {
|
||||||
|
serverUrl: string;
|
||||||
|
isPreregistered?: boolean;
|
||||||
|
}) => {
|
||||||
|
const key = getServerSpecificKey(
|
||||||
|
isPreregistered
|
||||||
|
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
|
||||||
|
: SESSION_KEYS.CLIENT_INFORMATION,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
};
|
||||||
|
|
||||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
constructor(public serverUrl: string) {
|
constructor(protected serverUrl: string) {
|
||||||
// Save the server URL to session storage
|
// Save the server URL to session storage
|
||||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||||
}
|
}
|
||||||
|
|
@ -31,24 +87,30 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async clientInformation() {
|
async clientInformation() {
|
||||||
const key = getServerSpecificKey(
|
// Try to get the preregistered client information from session storage first
|
||||||
SESSION_KEYS.CLIENT_INFORMATION,
|
const preregisteredClientInformation =
|
||||||
this.serverUrl,
|
await getClientInformationFromSessionStorage({
|
||||||
);
|
serverUrl: this.serverUrl,
|
||||||
const value = sessionStorage.getItem(key);
|
isPreregistered: true,
|
||||||
if (!value) {
|
});
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
|
// If no preregistered client information is found, get the dynamically registered client information
|
||||||
|
return (
|
||||||
|
preregisteredClientInformation ??
|
||||||
|
(await getClientInformationFromSessionStorage({
|
||||||
|
serverUrl: this.serverUrl,
|
||||||
|
isPreregistered: false,
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveClientInformation(clientInformation: OAuthClientInformation) {
|
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||||
const key = getServerSpecificKey(
|
// Save the dynamically registered client information to session storage
|
||||||
SESSION_KEYS.CLIENT_INFORMATION,
|
saveClientInformationToSessionStorage({
|
||||||
this.serverUrl,
|
serverUrl: this.serverUrl,
|
||||||
);
|
clientInformation,
|
||||||
sessionStorage.setItem(key, JSON.stringify(clientInformation));
|
isPreregistered: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async tokens() {
|
async tokens() {
|
||||||
|
|
@ -92,9 +154,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
sessionStorage.removeItem(
|
clearClientInformationFromSessionStorage({
|
||||||
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
|
serverUrl: this.serverUrl,
|
||||||
);
|
isPreregistered: false,
|
||||||
|
});
|
||||||
sessionStorage.removeItem(
|
sessionStorage.removeItem(
|
||||||
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
|
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export const SESSION_KEYS = {
|
||||||
SERVER_URL: "mcp_server_url",
|
SERVER_URL: "mcp_server_url",
|
||||||
TOKENS: "mcp_tokens",
|
TOKENS: "mcp_tokens",
|
||||||
CLIENT_INFORMATION: "mcp_client_information",
|
CLIENT_INFORMATION: "mcp_client_information",
|
||||||
|
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
|
||||||
SERVER_METADATA: "mcp_server_metadata",
|
SERVER_METADATA: "mcp_server_metadata",
|
||||||
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
|
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue