Merge branch 'main' into allow-multiple-client-connections
This commit is contained in:
commit
d0ba4185ee
12 changed files with 972 additions and 115 deletions
|
|
@ -25,6 +25,7 @@
|
|||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"ajv": "^6.12.6",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||
import { AuthDebuggerState } from "./lib/auth-types";
|
||||
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
|
|
@ -473,6 +474,8 @@ const App = () => {
|
|||
);
|
||||
setTools(response.tools);
|
||||
setNextToolCursor(response.nextCursor);
|
||||
// Cache output schemas for validation
|
||||
cacheToolOutputSchemas(response.tools);
|
||||
};
|
||||
|
||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||
|
|
@ -759,6 +762,8 @@ const App = () => {
|
|||
clearTools={() => {
|
||||
setTools([]);
|
||||
setNextToolCursor(undefined);
|
||||
// Clear cached output schemas
|
||||
cacheToolOutputSchemas([]);
|
||||
}}
|
||||
callTool={async (name, params) => {
|
||||
clearError("tools");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
|
||||
import {
|
||||
auth,
|
||||
discoverOAuthMetadata,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AuthDebuggerState } from "../lib/auth-types";
|
||||
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||
|
|
@ -124,22 +119,43 @@ const AuthDebugger = ({
|
|||
|
||||
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
|
||||
try {
|
||||
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
|
||||
serverUrl,
|
||||
);
|
||||
// First discover OAuth metadata separately so we can save it
|
||||
const metadata = await discoverOAuthMetadata(serverUrl);
|
||||
if (!metadata) {
|
||||
throw new Error("Failed to discover OAuth metadata");
|
||||
}
|
||||
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
|
||||
serverAuthProvider.saveServerMetadata(parsedMetadata);
|
||||
// Step through the OAuth flow using the state machine instead of the auth() function
|
||||
let currentState: AuthDebuggerState = {
|
||||
...authState,
|
||||
oauthStep: "metadata_discovery",
|
||||
authorizationUrl: null,
|
||||
latestError: null,
|
||||
};
|
||||
|
||||
await auth(serverAuthProvider, { serverUrl: serverUrl });
|
||||
const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => {
|
||||
// Update our temporary state during the process
|
||||
currentState = { ...currentState, ...updates };
|
||||
// But don't call updateAuthState yet
|
||||
});
|
||||
|
||||
// Manually step through each stage of the OAuth flow
|
||||
while (currentState.oauthStep !== "complete") {
|
||||
await oauthMachine.executeStep(currentState);
|
||||
// In quick mode, we'll just redirect to the authorization URL
|
||||
if (
|
||||
currentState.oauthStep === "authorization_code" &&
|
||||
currentState.authorizationUrl
|
||||
) {
|
||||
// Open the authorization URL automatically
|
||||
window.location.href = currentState.authorizationUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// After the flow completes or reaches a user-input step, update the app state
|
||||
updateAuthState({
|
||||
...currentState,
|
||||
statusMessage: {
|
||||
type: "info",
|
||||
message: "Starting OAuth authentication process...",
|
||||
message:
|
||||
currentState.oauthStep === "complete"
|
||||
? "Authentication completed successfully"
|
||||
: "Please complete authentication in the opened window and enter the code",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -153,7 +169,7 @@ const AuthDebugger = ({
|
|||
} finally {
|
||||
updateAuthState({ isInitiatingAuth: false });
|
||||
}
|
||||
}, [serverUrl, updateAuthState]);
|
||||
}, [serverUrl, updateAuthState, authState]);
|
||||
|
||||
const handleClearOAuth = useCallback(() => {
|
||||
if (serverUrl) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
|
|||
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
interface OAuthStepProps {
|
||||
label: string;
|
||||
|
|
@ -54,24 +56,57 @@ interface OAuthFlowProgressProps {
|
|||
proceedToNextStep: () => Promise<void>;
|
||||
}
|
||||
|
||||
const steps: Array<OAuthStep> = [
|
||||
"metadata_discovery",
|
||||
"client_registration",
|
||||
"authorization_redirect",
|
||||
"authorization_code",
|
||||
"token_request",
|
||||
"complete",
|
||||
];
|
||||
|
||||
export const OAuthFlowProgress = ({
|
||||
serverUrl,
|
||||
authState,
|
||||
updateAuthState,
|
||||
proceedToNextStep,
|
||||
}: OAuthFlowProgressProps) => {
|
||||
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
|
||||
const provider = useMemo(
|
||||
() => new DebugInspectorOAuthClientProvider(serverUrl),
|
||||
[serverUrl],
|
||||
);
|
||||
const [clientInfo, setClientInfo] = useState<OAuthClientInformation | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const steps: Array<OAuthStep> = [
|
||||
"metadata_discovery",
|
||||
"client_registration",
|
||||
"authorization_redirect",
|
||||
"authorization_code",
|
||||
"token_request",
|
||||
"complete",
|
||||
];
|
||||
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClientInfo = async () => {
|
||||
if (authState.oauthClientInfo) {
|
||||
setClientInfo(authState.oauthClientInfo);
|
||||
} else {
|
||||
try {
|
||||
const info = await provider.clientInformation();
|
||||
if (info) {
|
||||
setClientInfo(info);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch client information:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentStepIdx > steps.indexOf("client_registration")) {
|
||||
fetchClientInfo();
|
||||
}
|
||||
}, [
|
||||
provider,
|
||||
authState.oauthStep,
|
||||
authState.oauthClientInfo,
|
||||
currentStepIdx,
|
||||
]);
|
||||
|
||||
// Helper to get step props
|
||||
const getStepProps = (stepName: OAuthStep) => ({
|
||||
isComplete:
|
||||
|
|
@ -110,13 +145,13 @@ export const OAuthFlowProgress = ({
|
|||
label="Client Registration"
|
||||
{...getStepProps("client_registration")}
|
||||
>
|
||||
{authState.oauthClientInfo && (
|
||||
{clientInfo && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Registered Client Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(authState.oauthClientInfo, null, 2)}
|
||||
{JSON.stringify(clientInfo, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
|
|
|||
221
client/src/components/ToolResults.tsx
Normal file
221
client/src/components/ToolResults.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import JsonView from "./JsonView";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
CompatibilityCallToolResult,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
|
||||
|
||||
interface ToolResultsProps {
|
||||
toolResult: CompatibilityCallToolResult | null;
|
||||
selectedTool: Tool | null;
|
||||
}
|
||||
|
||||
const checkContentCompatibility = (
|
||||
structuredContent: unknown,
|
||||
unstructuredContent: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
}>,
|
||||
): { isCompatible: boolean; message: string } => {
|
||||
if (
|
||||
unstructuredContent.length !== 1 ||
|
||||
unstructuredContent[0].type !== "text"
|
||||
) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Unstructured content is not a single text block",
|
||||
};
|
||||
}
|
||||
|
||||
const textContent = unstructuredContent[0].text;
|
||||
if (!textContent) {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Text content is empty",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedContent = JSON.parse(textContent);
|
||||
const isEqual =
|
||||
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
|
||||
|
||||
if (isEqual) {
|
||||
return {
|
||||
isCompatible: true,
|
||||
message: "Unstructured content matches structured content",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Parsed JSON does not match structured content",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
isCompatible: false,
|
||||
message: "Unstructured content is not valid JSON",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
|
||||
if (!toolResult) return null;
|
||||
|
||||
if ("content" in toolResult) {
|
||||
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||
if (!parsedResult.success) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||
<JsonView data={toolResult} />
|
||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||
{parsedResult.error.errors.map((error, idx) => (
|
||||
<JsonView data={error} key={idx} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const structuredResult = parsedResult.data;
|
||||
const isError = structuredResult.isError ?? false;
|
||||
|
||||
let validationResult = null;
|
||||
const toolHasOutputSchema =
|
||||
selectedTool && hasOutputSchema(selectedTool.name);
|
||||
|
||||
if (toolHasOutputSchema) {
|
||||
if (!structuredResult.structuredContent && !isError) {
|
||||
validationResult = {
|
||||
isValid: false,
|
||||
error:
|
||||
"Tool has an output schema but did not return structured content",
|
||||
};
|
||||
} else if (structuredResult.structuredContent) {
|
||||
validationResult = validateToolOutput(
|
||||
selectedTool.name,
|
||||
structuredResult.structuredContent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let compatibilityResult = null;
|
||||
if (
|
||||
structuredResult.structuredContent &&
|
||||
structuredResult.content.length > 0 &&
|
||||
selectedTool &&
|
||||
hasOutputSchema(selectedTool.name)
|
||||
) {
|
||||
compatibilityResult = checkContentCompatibility(
|
||||
structuredResult.structuredContent,
|
||||
structuredResult.content,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">
|
||||
Tool Result:{" "}
|
||||
{isError ? (
|
||||
<span className="text-red-600 font-semibold">Error</span>
|
||||
) : (
|
||||
<span className="text-green-600 font-semibold">Success</span>
|
||||
)}
|
||||
</h4>
|
||||
{structuredResult.structuredContent && (
|
||||
<div className="mb-4">
|
||||
<h5 className="font-semibold mb-2 text-sm">Structured Content:</h5>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||
<JsonView data={structuredResult.structuredContent} />
|
||||
{validationResult && (
|
||||
<div
|
||||
className={`mt-2 p-2 rounded text-sm ${
|
||||
validationResult.isValid
|
||||
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
|
||||
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
|
||||
}`}
|
||||
>
|
||||
{validationResult.isValid ? (
|
||||
"✓ Valid according to output schema"
|
||||
) : (
|
||||
<>✗ Validation Error: {validationResult.error}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!structuredResult.structuredContent &&
|
||||
validationResult &&
|
||||
!validationResult.isValid && (
|
||||
<div className="mb-4">
|
||||
<div className="bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 p-2 rounded text-sm">
|
||||
✗ Validation Error: {validationResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{structuredResult.content.length > 0 && (
|
||||
<div className="mb-4">
|
||||
{structuredResult.structuredContent && (
|
||||
<>
|
||||
<h5 className="font-semibold mb-2 text-sm">
|
||||
Unstructured Content:
|
||||
</h5>
|
||||
{compatibilityResult && (
|
||||
<div
|
||||
className={`mb-2 p-2 rounded text-sm ${
|
||||
compatibilityResult.isCompatible
|
||||
? "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||
: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200"
|
||||
}`}
|
||||
>
|
||||
{compatibilityResult.isCompatible ? "✓" : "⚠"}{" "}
|
||||
{compatibilityResult.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{structuredResult.content.map((item, index) => (
|
||||
<div key={index} className="mb-2">
|
||||
{item.type === "text" && (
|
||||
<JsonView data={item.text} isError={isError} />
|
||||
)}
|
||||
{item.type === "image" && (
|
||||
<img
|
||||
src={`data:${item.mimeType};base64,${item.data}`}
|
||||
alt="Tool result image"
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
{item.type === "resource" &&
|
||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||
<audio
|
||||
controls
|
||||
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||
className="w-full"
|
||||
>
|
||||
<p>Your browser does not support audio playback</p>
|
||||
</audio>
|
||||
) : (
|
||||
<JsonView data={item.resource} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if ("toolResult" in toolResult) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||
<JsonView data={toolResult.toolResult} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ToolResults;
|
||||
|
|
@ -9,15 +9,15 @@ import DynamicJsonForm from "./DynamicJsonForm";
|
|||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
CompatibilityCallToolResult,
|
||||
ListToolsResult,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ListPane from "./ListPane";
|
||||
import JsonView from "./JsonView";
|
||||
import ToolResults from "./ToolResults";
|
||||
|
||||
const ToolsTab = ({
|
||||
tools,
|
||||
|
|
@ -41,6 +41,7 @@ const ToolsTab = ({
|
|||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = Object.entries(
|
||||
|
|
@ -52,75 +53,6 @@ const ToolsTab = ({
|
|||
setParams(Object.fromEntries(params));
|
||||
}, [selectedTool]);
|
||||
|
||||
const renderToolResult = () => {
|
||||
if (!toolResult) return null;
|
||||
|
||||
if ("content" in toolResult) {
|
||||
const parsedResult = CallToolResultSchema.safeParse(toolResult);
|
||||
if (!parsedResult.success) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
|
||||
<JsonView data={toolResult} />
|
||||
<h4 className="font-semibold mb-2">Errors:</h4>
|
||||
{parsedResult.error.errors.map((error, idx) => (
|
||||
<JsonView data={error} key={idx} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const structuredResult = parsedResult.data;
|
||||
const isError = structuredResult.isError ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">
|
||||
Tool Result:{" "}
|
||||
{isError ? (
|
||||
<span className="text-red-600 font-semibold">Error</span>
|
||||
) : (
|
||||
<span className="text-green-600 font-semibold">Success</span>
|
||||
)}
|
||||
</h4>
|
||||
{structuredResult.content.map((item, index) => (
|
||||
<div key={index} className="mb-2">
|
||||
{item.type === "text" && (
|
||||
<JsonView data={item.text} isError={isError} />
|
||||
)}
|
||||
{item.type === "image" && (
|
||||
<img
|
||||
src={`data:${item.mimeType};base64,${item.data}`}
|
||||
alt="Tool result image"
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
{item.type === "resource" &&
|
||||
(item.resource?.mimeType?.startsWith("audio/") ? (
|
||||
<audio
|
||||
controls
|
||||
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
|
||||
className="w-full"
|
||||
>
|
||||
<p>Your browser does not support audio playback</p>
|
||||
</audio>
|
||||
) : (
|
||||
<JsonView data={item.resource} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else if ("toolResult" in toolResult) {
|
||||
return (
|
||||
<>
|
||||
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
|
||||
|
||||
<JsonView data={toolResult.toolResult} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TabsContent value="tools">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -262,6 +194,42 @@ const ToolsTab = ({
|
|||
);
|
||||
},
|
||||
)}
|
||||
{selectedTool.outputSchema && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-3 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold">Output Schema:</h4>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setIsOutputSchemaExpanded(!isOutputSchemaExpanded)
|
||||
}
|
||||
className="h-6 px-2"
|
||||
>
|
||||
{isOutputSchemaExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Expand
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all ${
|
||||
isOutputSchemaExpanded
|
||||
? ""
|
||||
: "max-h-[8rem] overflow-y-auto"
|
||||
}`}
|
||||
>
|
||||
<JsonView data={selectedTool.outputSchema} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
|
@ -285,7 +253,10 @@ const ToolsTab = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
{toolResult && renderToolResult()}
|
||||
<ToolResults
|
||||
toolResult={toolResult}
|
||||
selectedTool={selectedTool}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
|
|
|
|||
|
|
@ -207,16 +207,10 @@ describe("AuthDebugger", () => {
|
|||
"https://example.com",
|
||||
);
|
||||
|
||||
// Then should call auth with the server provider
|
||||
expect(mockAuth).toHaveBeenCalled();
|
||||
|
||||
// Check that updateAuthState was called with the right info message
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
statusMessage: {
|
||||
type: "info",
|
||||
message: "Starting OAuth authentication process...",
|
||||
},
|
||||
oauthStep: "authorization_code",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||
import ToolsTab from "../ToolsTab";
|
||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
|
||||
|
||||
describe("ToolsTab", () => {
|
||||
beforeEach(() => {
|
||||
// Clear the output schema cache before each test
|
||||
cacheToolOutputSchemas([]);
|
||||
});
|
||||
|
||||
const mockTools: Tool[] = [
|
||||
{
|
||||
name: "tool1",
|
||||
|
|
@ -141,4 +147,217 @@ describe("ToolsTab", () => {
|
|||
|
||||
expect(submitButton.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
describe("Output Schema Display", () => {
|
||||
const toolWithOutputSchema: Tool = {
|
||||
name: "weatherTool",
|
||||
description: "Get weather",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
city: { type: "string" as const },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
temperature: { type: "number" as const },
|
||||
humidity: { type: "number" as const },
|
||||
},
|
||||
required: ["temperature", "humidity"],
|
||||
},
|
||||
};
|
||||
|
||||
it("should display output schema when tool has one", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Output Schema:")).toBeInTheDocument();
|
||||
// Check for expand/collapse button
|
||||
expect(
|
||||
screen.getByRole("button", { name: /expand/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display output schema section when tool doesn't have one", () => {
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0], // Tool without outputSchema
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Output Schema:")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle output schema expansion", () => {
|
||||
renderToolsTab({
|
||||
tools: [toolWithOutputSchema],
|
||||
selectedTool: toolWithOutputSchema,
|
||||
});
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: /expand/i });
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /collapse/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(toggleButton);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /expand/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Structured Output Results", () => {
|
||||
const toolWithOutputSchema: Tool = {
|
||||
name: "weatherTool",
|
||||
description: "Get weather",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
temperature: { type: "number" as const },
|
||||
},
|
||||
required: ["temperature"],
|
||||
},
|
||||
};
|
||||
|
||||
it("should display structured content when present", () => {
|
||||
// Cache the tool's output schema so hasOutputSchema returns true
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const structuredResult = {
|
||||
content: [],
|
||||
structuredContent: {
|
||||
temperature: 25,
|
||||
},
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: structuredResult,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Valid according to output schema/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show validation error for invalid structured content", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const invalidResult = {
|
||||
content: [],
|
||||
structuredContent: {
|
||||
temperature: "25", // String instead of number
|
||||
},
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: invalidResult,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Validation Error:/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error when tool with output schema doesn't return structured content", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const resultWithoutStructured = {
|
||||
content: [{ type: "text", text: "some result" }],
|
||||
// No structuredContent
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: resultWithoutStructured,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Tool has an output schema but did not return structured content/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show unstructured content title when both structured and unstructured exist", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const resultWithBoth = {
|
||||
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: resultWithBoth,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Structured Content:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unstructured Content:")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show unstructured content title when only unstructured exists", () => {
|
||||
const resultWithUnstructuredOnly = {
|
||||
content: [{ type: "text", text: "some result" }],
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0], // Tool without output schema
|
||||
toolResult: resultWithUnstructuredOnly,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText("Unstructured Content:"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show compatibility check when tool has output schema", () => {
|
||||
cacheToolOutputSchemas([toolWithOutputSchema]);
|
||||
|
||||
const compatibleResult = {
|
||||
content: [{ type: "text", text: '{"temperature": 25}' }],
|
||||
structuredContent: { temperature: 25 },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: toolWithOutputSchema,
|
||||
toolResult: compatibleResult,
|
||||
});
|
||||
|
||||
// Should show compatibility result
|
||||
expect(
|
||||
screen.getByText(
|
||||
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show compatibility check when tool has no output schema", () => {
|
||||
const resultWithBoth = {
|
||||
content: [{ type: "text", text: '{"data": "value"}' }],
|
||||
structuredContent: { different: "data" },
|
||||
};
|
||||
|
||||
renderToolsTab({
|
||||
selectedTool: mockTools[0], // Tool without output schema
|
||||
toolResult: resultWithBoth,
|
||||
});
|
||||
|
||||
// Should not show any compatibility messages
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/matches structured content|not a single text block|not valid JSON|does not match/,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,10 +28,18 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|||
}));
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
SSEClientTransport: jest.fn(),
|
||||
SSEClientTransport: jest.fn((url) => ({
|
||||
toString: () => url,
|
||||
})),
|
||||
SseError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: jest.fn((url) => ({
|
||||
toString: () => url,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
|
||||
}));
|
||||
|
|
@ -163,4 +171,92 @@ describe("useConnection", () => {
|
|||
result.current.makeRequest(mockRequest, mockSchema),
|
||||
).rejects.toThrow("MCP client not connected");
|
||||
});
|
||||
|
||||
describe("URL Port Handling", () => {
|
||||
const SSEClientTransport = jest.requireMock(
|
||||
"@modelcontextprotocol/sdk/client/sse.js",
|
||||
).SSEClientTransport;
|
||||
const StreamableHTTPClientTransport = jest.requireMock(
|
||||
"@modelcontextprotocol/sdk/client/streamableHttp.js",
|
||||
).StreamableHTTPClientTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("preserves HTTPS port number when connecting", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
sseUrl: "https://example.com:8443/api",
|
||||
transportType: "sse" as const,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(props));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const call = SSEClientTransport.mock.calls[0][0];
|
||||
expect(call.toString()).toContain(
|
||||
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves HTTP port number when connecting", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
sseUrl: "http://localhost:3000/api",
|
||||
transportType: "sse" as const,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(props));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const call = SSEClientTransport.mock.calls[0][0];
|
||||
expect(call.toString()).toContain(
|
||||
"url=http%3A%2F%2Flocalhost%3A3000%2Fapi",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses default port for HTTPS when not specified", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
sseUrl: "https://example.com/api",
|
||||
transportType: "sse" as const,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(props));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const call = SSEClientTransport.mock.calls[0][0];
|
||||
expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi");
|
||||
expect(call.toString()).not.toContain("%3A443");
|
||||
});
|
||||
|
||||
test("preserves port number in streamable-http transport", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
sseUrl: "https://example.com:8443/api",
|
||||
transportType: "streamable-http" as const,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(props));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
const call = StreamableHTTPClientTransport.mock.calls[0][0];
|
||||
expect(call.toString()).toContain(
|
||||
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
|
||||
import {
|
||||
generateDefaultValue,
|
||||
formatFieldLabel,
|
||||
cacheToolOutputSchemas,
|
||||
getToolOutputValidator,
|
||||
validateToolOutput,
|
||||
hasOutputSchema,
|
||||
} from "../schemaUtils";
|
||||
import type { JsonSchemaType } from "../jsonUtils";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
describe("generateDefaultValue", () => {
|
||||
test("generates default string", () => {
|
||||
|
|
@ -137,3 +145,220 @@ describe("formatFieldLabel", () => {
|
|||
expect(formatFieldLabel("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Output Schema Validation", () => {
|
||||
const mockTools: Tool[] = [
|
||||
{
|
||||
name: "weatherTool",
|
||||
description: "Get weather information",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string" },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
temperature: { type: "number" },
|
||||
humidity: { type: "number" },
|
||||
},
|
||||
required: ["temperature", "humidity"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "noOutputSchema",
|
||||
description: "Tool without output schema",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complexOutputSchema",
|
||||
description: "Tool with complex output schema",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["user"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear cache before each test
|
||||
cacheToolOutputSchemas([]);
|
||||
});
|
||||
|
||||
describe("cacheToolOutputSchemas", () => {
|
||||
test("caches validators for tools with output schemas", () => {
|
||||
cacheToolOutputSchemas(mockTools);
|
||||
|
||||
expect(hasOutputSchema("weatherTool")).toBe(true);
|
||||
expect(hasOutputSchema("complexOutputSchema")).toBe(true);
|
||||
expect(hasOutputSchema("noOutputSchema")).toBe(false);
|
||||
});
|
||||
|
||||
test("clears existing cache when called", () => {
|
||||
cacheToolOutputSchemas(mockTools);
|
||||
expect(hasOutputSchema("weatherTool")).toBe(true);
|
||||
|
||||
cacheToolOutputSchemas([]);
|
||||
expect(hasOutputSchema("weatherTool")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles invalid output schemas gracefully", () => {
|
||||
const toolsWithInvalidSchema: Tool[] = [
|
||||
{
|
||||
name: "invalidSchemaTool",
|
||||
description: "Tool with invalid schema",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: {
|
||||
// @ts-expect-error Testing with invalid type
|
||||
type: "invalid-type",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
cacheToolOutputSchemas(toolsWithInvalidSchema),
|
||||
).not.toThrow();
|
||||
expect(hasOutputSchema("invalidSchemaTool")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateToolOutput", () => {
|
||||
beforeEach(() => {
|
||||
cacheToolOutputSchemas(mockTools);
|
||||
});
|
||||
|
||||
test("validates correct structured content", () => {
|
||||
const result = validateToolOutput("weatherTool", {
|
||||
temperature: 25.5,
|
||||
humidity: 60,
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects invalid structured content", () => {
|
||||
const result = validateToolOutput("weatherTool", {
|
||||
temperature: "25.5", // Should be number
|
||||
humidity: 60,
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain("should be number");
|
||||
});
|
||||
|
||||
test("rejects missing required fields", () => {
|
||||
const result = validateToolOutput("weatherTool", {
|
||||
temperature: 25.5,
|
||||
// Missing humidity
|
||||
});
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toContain("required");
|
||||
});
|
||||
|
||||
test("validates complex nested structures", () => {
|
||||
const validResult = validateToolOutput("complexOutputSchema", {
|
||||
user: {
|
||||
name: "John",
|
||||
age: 30,
|
||||
},
|
||||
tags: ["tag1", "tag2"],
|
||||
});
|
||||
|
||||
expect(validResult.isValid).toBe(true);
|
||||
|
||||
const invalidResult = validateToolOutput("complexOutputSchema", {
|
||||
user: {
|
||||
// Missing required 'name'
|
||||
age: 30,
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalidResult.isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("returns valid for tools without validators", () => {
|
||||
const result = validateToolOutput("nonExistentTool", { any: "data" });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("validates additional properties restriction", () => {
|
||||
const result = validateToolOutput("weatherTool", {
|
||||
temperature: 25.5,
|
||||
humidity: 60,
|
||||
extraField: "should not be here",
|
||||
});
|
||||
|
||||
// This depends on whether additionalProperties is set to false in the schema
|
||||
// If it is, this should fail
|
||||
expect(result.isValid).toBe(true); // By default, additional properties are allowed
|
||||
});
|
||||
});
|
||||
|
||||
describe("getToolOutputValidator", () => {
|
||||
beforeEach(() => {
|
||||
cacheToolOutputSchemas(mockTools);
|
||||
});
|
||||
|
||||
test("returns validator for cached tool", () => {
|
||||
const validator = getToolOutputValidator("weatherTool");
|
||||
expect(validator).toBeDefined();
|
||||
expect(typeof validator).toBe("function");
|
||||
});
|
||||
|
||||
test("returns undefined for tool without output schema", () => {
|
||||
const validator = getToolOutputValidator("noOutputSchema");
|
||||
expect(validator).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for non-existent tool", () => {
|
||||
const validator = getToolOutputValidator("nonExistentTool");
|
||||
expect(validator).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasOutputSchema", () => {
|
||||
beforeEach(() => {
|
||||
cacheToolOutputSchemas(mockTools);
|
||||
});
|
||||
|
||||
test("returns true for tools with output schemas", () => {
|
||||
expect(hasOutputSchema("weatherTool")).toBe(true);
|
||||
expect(hasOutputSchema("complexOutputSchema")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for tools without output schemas", () => {
|
||||
expect(hasOutputSchema("noOutputSchema")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-existent tools", () => {
|
||||
expect(hasOutputSchema("nonExistentTool")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,82 @@
|
|||
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
|
||||
import Ajv from "ajv";
|
||||
import type { ValidateFunction } from "ajv";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
// Cache for compiled validators
|
||||
const toolOutputValidators = new Map<string, ValidateFunction>();
|
||||
|
||||
/**
|
||||
* Compiles and caches output schema validators for a list of tools
|
||||
* Following the same pattern as SDK's Client.cacheToolOutputSchemas
|
||||
* @param tools Array of tools that may have output schemas
|
||||
*/
|
||||
export function cacheToolOutputSchemas(tools: Tool[]): void {
|
||||
toolOutputValidators.clear();
|
||||
for (const tool of tools) {
|
||||
if (tool.outputSchema) {
|
||||
try {
|
||||
const validator = ajv.compile(tool.outputSchema);
|
||||
toolOutputValidators.set(tool.name, validator);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to compile output schema for tool ${tool.name}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cached output schema validator for a tool
|
||||
* Following the same pattern as SDK's Client.getToolOutputValidator
|
||||
* @param toolName Name of the tool
|
||||
* @returns The compiled validator function, or undefined if not found
|
||||
*/
|
||||
export function getToolOutputValidator(
|
||||
toolName: string,
|
||||
): ValidateFunction | undefined {
|
||||
return toolOutputValidators.get(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates structured content against a tool's output schema
|
||||
* Returns validation result with detailed error messages
|
||||
* @param toolName Name of the tool
|
||||
* @param structuredContent The structured content to validate
|
||||
* @returns An object with isValid boolean and optional error message
|
||||
*/
|
||||
export function validateToolOutput(
|
||||
toolName: string,
|
||||
structuredContent: unknown,
|
||||
): { isValid: boolean; error?: string } {
|
||||
const validator = getToolOutputValidator(toolName);
|
||||
if (!validator) {
|
||||
return { isValid: true }; // No validator means no schema to validate against
|
||||
}
|
||||
|
||||
const isValid = validator(structuredContent);
|
||||
if (!isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: ajv.errorsText(validator.errors),
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool has an output schema
|
||||
* @param toolName Name of the tool
|
||||
* @returns true if the tool has an output schema
|
||||
*/
|
||||
export function hasOutputSchema(toolName: string): boolean {
|
||||
return toolOutputValidators.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default value based on a JSON schema type
|
||||
|
|
|
|||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -77,6 +77,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"ajv": "^6.12.6",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
|
|
@ -4002,7 +4003,6 @@
|
|||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
|
|
@ -5710,7 +5710,6 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
|
|
@ -7740,7 +7739,6 @@
|
|||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
|
|
@ -8812,7 +8810,6 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
@ -10488,7 +10485,6 @@
|
|||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue