Merge branch 'main' into resizable-left-sidebar
This commit is contained in:
commit
13d31596af
18 changed files with 1066 additions and 168 deletions
19
README.md
19
README.md
|
|
@ -4,12 +4,31 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The MCP Inspector consists of two main components that work together:
|
||||||
|
|
||||||
|
- **MCP Inspector Client (MCPI)**: A React-based web UI that provides an interactive interface for testing and debugging MCP servers
|
||||||
|
- **MCP Proxy (MCPP)**: A Node.js server that acts as a protocol bridge, connecting the web UI to MCP servers via various transport methods (stdio, SSE, streamable-http)
|
||||||
|
|
||||||
|
Note that the proxy is not a network proxy for intercepting traffic. Instead, it functions as both an MCP client (connecting to your MCP server) and an HTTP server (serving the web UI), enabling browser-based interaction with MCP servers that use different transport protocols.
|
||||||
|
|
||||||
## Running the Inspector
|
## Running the Inspector
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js: ^22.7.5
|
- Node.js: ^22.7.5
|
||||||
|
|
||||||
|
### Quick Start (UI mode)
|
||||||
|
|
||||||
|
To get up and running right away with the UI, just execute the following:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start up and the UI will be accessible at `http://localhost:6274`.
|
||||||
|
|
||||||
### From an MCP server repository
|
### From an MCP server repository
|
||||||
|
|
||||||
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
|
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.5",
|
"@modelcontextprotocol/sdk": "^1.11.5",
|
||||||
"@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",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
||||||
import { AuthDebuggerState } from "./lib/auth-types";
|
import { AuthDebuggerState } from "./lib/auth-types";
|
||||||
|
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
||||||
import React, {
|
import React, {
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
|
@ -481,6 +482,8 @@ const App = () => {
|
||||||
);
|
);
|
||||||
setTools(response.tools);
|
setTools(response.tools);
|
||||||
setNextToolCursor(response.nextCursor);
|
setNextToolCursor(response.nextCursor);
|
||||||
|
// Cache output schemas for validation
|
||||||
|
cacheToolOutputSchemas(response.tools);
|
||||||
};
|
};
|
||||||
|
|
||||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||||
|
|
@ -642,7 +645,7 @@ const App = () => {
|
||||||
className="w-full p-4"
|
className="w-full p-4"
|
||||||
onValueChange={(value) => (window.location.hash = value)}
|
onValueChange={(value) => (window.location.hash = value)}
|
||||||
>
|
>
|
||||||
<TabsList className="mb-4 p-0">
|
<TabsList className="mb-4 py-0">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="resources"
|
value="resources"
|
||||||
disabled={!serverCapabilities?.resources}
|
disabled={!serverCapabilities?.resources}
|
||||||
|
|
@ -693,7 +696,7 @@ const App = () => {
|
||||||
!serverCapabilities?.tools ? (
|
!serverCapabilities?.tools ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-center p-4">
|
<div className="flex items-center justify-center p-4">
|
||||||
<p className="text-lg text-gray-500">
|
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||||
The connected server does not support any MCP
|
The connected server does not support any MCP
|
||||||
capabilities
|
capabilities
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -793,6 +796,8 @@ const App = () => {
|
||||||
clearTools={() => {
|
clearTools={() => {
|
||||||
setTools([]);
|
setTools([]);
|
||||||
setNextToolCursor(undefined);
|
setNextToolCursor(undefined);
|
||||||
|
// Clear cached output schemas
|
||||||
|
cacheToolOutputSchemas([]);
|
||||||
}}
|
}}
|
||||||
callTool={async (name, params) => {
|
callTool={async (name, params) => {
|
||||||
clearError("tools");
|
clearError("tools");
|
||||||
|
|
@ -845,7 +850,7 @@ const App = () => {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
<p className="text-lg text-gray-500">
|
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||||
Connect to an MCP server to start inspecting
|
Connect to an MCP server to start inspecting
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -870,7 +875,7 @@ const App = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50"
|
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-1 rounded-full bg-border" />
|
<div className="w-8 h-1 rounded-full bg-border" />
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ const HistoryAndNotifications = ({
|
||||||
<div className="flex-1 overflow-y-auto p-4 border-r">
|
<div className="flex-1 overflow-y-auto p-4 border-r">
|
||||||
<h2 className="text-lg font-semibold mb-4">History</h2>
|
<h2 className="text-lg font-semibold mb-4">History</h2>
|
||||||
{requestHistory.length === 0 ? (
|
{requestHistory.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 italic">No history yet</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
No history yet
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{requestHistory
|
{requestHistory
|
||||||
|
|
@ -38,7 +40,7 @@ const HistoryAndNotifications = ({
|
||||||
.map((request, index) => (
|
.map((request, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
className="text-sm text-foreground bg-secondary p-2 rounded"
|
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center cursor-pointer"
|
className="flex justify-between items-center cursor-pointer"
|
||||||
|
|
@ -93,7 +95,9 @@ const HistoryAndNotifications = ({
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
||||||
{serverNotifications.length === 0 ? (
|
{serverNotifications.length === 0 ? (
|
||||||
<p className="text-sm text-gray-500 italic">No notifications yet</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||||
|
No notifications yet
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{serverNotifications
|
{serverNotifications
|
||||||
|
|
@ -102,7 +106,7 @@ const HistoryAndNotifications = ({
|
||||||
.map((notification, index) => (
|
.map((notification, index) => (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={index}
|
||||||
className="text-sm text-foreground bg-secondary p-2 rounded"
|
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex justify-between items-center cursor-pointer"
|
className="flex justify-between items-center cursor-pointer"
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ const ListPane = <T extends object>({
|
||||||
buttonText,
|
buttonText,
|
||||||
isButtonDisabled,
|
isButtonDisabled,
|
||||||
}: ListPaneProps<T>) => (
|
}: ListPaneProps<T>) => (
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card border border-border rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||||
<h3 className="font-semibold dark:text-white">{title}</h3>
|
<h3 className="font-semibold dark:text-white">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|
@ -46,7 +46,7 @@ const ListPane = <T extends object>({
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
|
||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
>
|
>
|
||||||
{renderItem(item)}
|
{renderItem(item)}
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,8 @@ const PromptsTab = ({
|
||||||
isButtonDisabled={!nextCursor && prompts.length > 0}
|
isButtonDisabled={!nextCursor && prompts.length > 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card border border-border rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -126,7 +126,7 @@ const PromptsTab = ({
|
||||||
) : selectedPrompt ? (
|
) : selectedPrompt ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedPrompt.description && (
|
{selectedPrompt.description && (
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{selectedPrompt.description}
|
{selectedPrompt.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -173,8 +173,8 @@ const ResourcesTab = ({
|
||||||
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card border border-border rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
|
<div className="p-4 border-b border-gray-200 dark:border-border flex justify-between items-center">
|
||||||
<h3
|
<h3
|
||||||
className="font-semibold truncate"
|
className="font-semibold truncate"
|
||||||
title={selectedResource?.name || selectedTemplate?.name}
|
title={selectedResource?.name || selectedTemplate?.name}
|
||||||
|
|
@ -234,7 +234,7 @@ const ResourcesTab = ({
|
||||||
/>
|
/>
|
||||||
) : selectedTemplate ? (
|
) : selectedTemplate ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{selectedTemplate.description}
|
{selectedTemplate.description}
|
||||||
</p>
|
</p>
|
||||||
{selectedTemplate.uriTemplate
|
{selectedTemplate.uriTemplate
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ const Sidebar = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border-r border-border flex flex-col h-full">
|
<div className="bg-card border-r border-border flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-border">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<h1 className="ml-2 text-lg font-semibold">
|
<h1 className="ml-2 text-lg font-semibold">
|
||||||
MCP Inspector v{version}
|
MCP Inspector v{version}
|
||||||
|
|
@ -646,7 +646,7 @@ const Sidebar = ({
|
||||||
}
|
}
|
||||||
})()}`}
|
})()}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{(() => {
|
{(() => {
|
||||||
switch (connectionStatus) {
|
switch (connectionStatus) {
|
||||||
case "connected":
|
case "connected":
|
||||||
|
|
|
||||||
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 type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} 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 { useEffect, useState } from "react";
|
||||||
import ListPane from "./ListPane";
|
import ListPane from "./ListPane";
|
||||||
import JsonView from "./JsonView";
|
import JsonView from "./JsonView";
|
||||||
|
import ToolResults from "./ToolResults";
|
||||||
|
|
||||||
const ToolsTab = ({
|
const ToolsTab = ({
|
||||||
tools,
|
tools,
|
||||||
|
|
@ -41,6 +41,7 @@ const ToolsTab = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
const [isToolRunning, setIsToolRunning] = useState(false);
|
const [isToolRunning, setIsToolRunning] = useState(false);
|
||||||
|
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = Object.entries(
|
const params = Object.entries(
|
||||||
|
|
@ -52,75 +53,6 @@ const ToolsTab = ({
|
||||||
setParams(Object.fromEntries(params));
|
setParams(Object.fromEntries(params));
|
||||||
}, [selectedTool]);
|
}, [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 (
|
return (
|
||||||
<TabsContent value="tools">
|
<TabsContent value="tools">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -145,8 +77,8 @@ const ToolsTab = ({
|
||||||
isButtonDisabled={!nextCursor && tools.length > 0}
|
isButtonDisabled={!nextCursor && tools.length > 0}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-card rounded-lg shadow">
|
<div className="bg-card border border-border rounded-lg shadow">
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
|
<div className="p-4 border-b border-gray-200 dark:border-border">
|
||||||
<h3 className="font-semibold">
|
<h3 className="font-semibold">
|
||||||
{selectedTool ? selectedTool.name : "Select a tool"}
|
{selectedTool ? selectedTool.name : "Select a tool"}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -154,7 +86,7 @@ const ToolsTab = ({
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{selectedTool ? (
|
{selectedTool ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{selectedTool.description}
|
{selectedTool.description}
|
||||||
</p>
|
</p>
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
|
|
@ -164,7 +96,7 @@ const ToolsTab = ({
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={key}
|
htmlFor={key}
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
{key}
|
{key}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
@ -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
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -285,7 +253,10 @@ const ToolsTab = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{toolResult && renderToolResult()}
|
<ToolResults
|
||||||
|
toolResult={toolResult}
|
||||||
|
selectedTool={selectedTool}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Alert>
|
<Alert>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
import { describe, it, expect, jest } from "@jest/globals";
|
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
import { describe, it, jest, beforeEach } from "@jest/globals";
|
||||||
import ToolsTab from "../ToolsTab";
|
import ToolsTab from "../ToolsTab";
|
||||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
|
import { cacheToolOutputSchemas } from "@/utils/schemaUtils";
|
||||||
|
|
||||||
describe("ToolsTab", () => {
|
describe("ToolsTab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear the output schema cache before each test
|
||||||
|
cacheToolOutputSchemas([]);
|
||||||
|
});
|
||||||
|
|
||||||
const mockTools: Tool[] = [
|
const mockTools: Tool[] = [
|
||||||
{
|
{
|
||||||
name: "tool1",
|
name: "tool1",
|
||||||
|
|
@ -141,4 +147,217 @@ describe("ToolsTab", () => {
|
||||||
|
|
||||||
expect(submitButton.getAttribute("disabled")).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,8 @@ h1 {
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 210 40% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 24% 24%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 24% 24%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 212.7 26.8% 83.9%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,18 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||||
SSEClientTransport: jest.fn(),
|
SSEClientTransport: jest.fn((url) => ({
|
||||||
|
toString: () => url,
|
||||||
|
})),
|
||||||
SseError: jest.fn(),
|
SseError: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
|
StreamableHTTPClientTransport: jest.fn((url) => ({
|
||||||
|
toString: () => url,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
|
auth: jest.fn().mockResolvedValue("AUTHORIZED"),
|
||||||
}));
|
}));
|
||||||
|
|
@ -163,4 +171,92 @@ describe("useConnection", () => {
|
||||||
result.current.makeRequest(mockRequest, mockSchema),
|
result.current.makeRequest(mockRequest, mockSchema),
|
||||||
).rejects.toThrow("MCP client not connected");
|
).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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
} from "@/utils/configUtils";
|
} from "@/utils/configUtils";
|
||||||
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||||
import { InspectorConfig } from "../configurationTypes";
|
import { InspectorConfig } from "../configurationTypes";
|
||||||
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
|
||||||
interface UseConnectionOptions {
|
interface UseConnectionOptions {
|
||||||
transportType: "stdio" | "sse" | "streamable-http";
|
transportType: "stdio" | "sse" | "streamable-http";
|
||||||
|
|
@ -83,6 +84,9 @@ export function useConnection({
|
||||||
const [serverCapabilities, setServerCapabilities] =
|
const [serverCapabilities, setServerCapabilities] =
|
||||||
useState<ServerCapabilities | null>(null);
|
useState<ServerCapabilities | null>(null);
|
||||||
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
const [mcpClient, setMcpClient] = useState<Client | null>(null);
|
||||||
|
const [clientTransport, setClientTransport] = useState<Transport | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [requestHistory, setRequestHistory] = useState<
|
const [requestHistory, setRequestHistory] = useState<
|
||||||
{ request: string; response?: string }[]
|
{ request: string; response?: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
@ -304,7 +308,14 @@ export function useConnection({
|
||||||
bearerToken || (await serverAuthProvider.tokens())?.access_token;
|
bearerToken || (await serverAuthProvider.tokens())?.access_token;
|
||||||
if (token) {
|
if (token) {
|
||||||
const authHeaderName = headerName || "Authorization";
|
const authHeaderName = headerName || "Authorization";
|
||||||
headers[authHeaderName] = `Bearer ${token}`;
|
|
||||||
|
// Add custom header name as a special request header to let the server know which header to pass through
|
||||||
|
if (authHeaderName.toLowerCase() !== "authorization") {
|
||||||
|
headers[authHeaderName] = token;
|
||||||
|
headers["x-custom-auth-header"] = authHeaderName;
|
||||||
|
} else {
|
||||||
|
headers[authHeaderName] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create appropriate transport
|
// Create appropriate transport
|
||||||
|
|
@ -377,14 +388,6 @@ export function useConnection({
|
||||||
transportType,
|
transportType,
|
||||||
);
|
);
|
||||||
|
|
||||||
const clientTransport =
|
|
||||||
transportType === "streamable-http"
|
|
||||||
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
|
||||||
sessionId: undefined,
|
|
||||||
...transportOptions,
|
|
||||||
})
|
|
||||||
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);
|
|
||||||
|
|
||||||
if (onNotification) {
|
if (onNotification) {
|
||||||
[
|
[
|
||||||
CancelledNotificationSchema,
|
CancelledNotificationSchema,
|
||||||
|
|
@ -414,7 +417,20 @@ export function useConnection({
|
||||||
|
|
||||||
let capabilities;
|
let capabilities;
|
||||||
try {
|
try {
|
||||||
await client.connect(clientTransport);
|
const transport =
|
||||||
|
transportType === "streamable-http"
|
||||||
|
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
|
||||||
|
sessionId: undefined,
|
||||||
|
...transportOptions,
|
||||||
|
})
|
||||||
|
: new SSEClientTransport(
|
||||||
|
mcpProxyServerUrl as URL,
|
||||||
|
transportOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.connect(transport as Transport);
|
||||||
|
|
||||||
|
setClientTransport(transport);
|
||||||
|
|
||||||
capabilities = client.getServerCapabilities();
|
capabilities = client.getServerCapabilities();
|
||||||
const initializeRequest = {
|
const initializeRequest = {
|
||||||
|
|
@ -468,10 +484,15 @@ export function useConnection({
|
||||||
};
|
};
|
||||||
|
|
||||||
const disconnect = async () => {
|
const disconnect = async () => {
|
||||||
|
if (transportType === "streamable-http")
|
||||||
|
await (
|
||||||
|
clientTransport as StreamableHTTPClientTransport
|
||||||
|
).terminateSession();
|
||||||
await mcpClient?.close();
|
await mcpClient?.close();
|
||||||
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
const authProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||||
authProvider.clear();
|
authProvider.clear();
|
||||||
setMcpClient(null);
|
setMcpClient(null);
|
||||||
|
setClientTransport(null);
|
||||||
setConnectionStatus("disconnected");
|
setConnectionStatus("disconnected");
|
||||||
setCompletionsSupported(false);
|
setCompletionsSupported(false);
|
||||||
setServerCapabilities(null);
|
setServerCapabilities(null);
|
||||||
|
|
|
||||||
|
|
@ -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 { JsonSchemaType } from "../jsonUtils";
|
||||||
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
describe("generateDefaultValue", () => {
|
describe("generateDefaultValue", () => {
|
||||||
test("generates default string", () => {
|
test("generates default string", () => {
|
||||||
|
|
@ -137,3 +145,220 @@ describe("formatFieldLabel", () => {
|
||||||
expect(formatFieldLabel("")).toBe("");
|
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 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
|
* 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-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",
|
||||||
|
|
@ -4002,7 +4003,6 @@
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
|
@ -5710,7 +5710,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-levenshtein": {
|
"node_modules/fast-levenshtein": {
|
||||||
|
|
@ -7740,7 +7739,6 @@
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
|
|
@ -8812,7 +8810,6 @@
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
|
|
@ -10488,7 +10485,6 @@
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,44 @@ const { values } = parseArgs({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Function to get HTTP headers.
|
||||||
|
// Supports only "sse" and "streamable-http" transport types.
|
||||||
|
const getHttpHeaders = (
|
||||||
|
req: express.Request,
|
||||||
|
transportType: string,
|
||||||
|
): HeadersInit => {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
Accept:
|
||||||
|
transportType === "sse"
|
||||||
|
? "text/event-stream"
|
||||||
|
: "text/event-stream, application/json",
|
||||||
|
};
|
||||||
|
const defaultHeaders =
|
||||||
|
transportType === "sse"
|
||||||
|
? SSE_HEADERS_PASSTHROUGH
|
||||||
|
: STREAMABLE_HTTP_HEADERS_PASSTHROUGH;
|
||||||
|
|
||||||
|
for (const key of defaultHeaders) {
|
||||||
|
if (req.headers[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = req.headers[key];
|
||||||
|
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the header "x-custom-auth-header" is present, use its value as the custom header name.
|
||||||
|
if (req.headers["x-custom-auth-header"] !== undefined) {
|
||||||
|
const customHeaderName = req.headers["x-custom-auth-header"] as string;
|
||||||
|
const lowerCaseHeaderName = customHeaderName.toLowerCase();
|
||||||
|
if (req.headers[lowerCaseHeaderName] !== undefined) {
|
||||||
|
const value = req.headers[lowerCaseHeaderName];
|
||||||
|
headers[customHeaderName] = value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|
@ -48,7 +86,8 @@ app.use((req, res, next) => {
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Transports by sessionId
|
const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Web app transports by web app sessionId
|
||||||
|
const serverTransports: Map<string, Transport> = new Map<string, Transport>(); // Server Transports by web app sessionId
|
||||||
|
|
||||||
const createTransport = async (req: express.Request): Promise<Transport> => {
|
const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
|
|
@ -79,18 +118,8 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
return transport;
|
return transport;
|
||||||
} else if (transportType === "sse") {
|
} else if (transportType === "sse") {
|
||||||
const url = query.url as string;
|
const url = query.url as string;
|
||||||
const headers: HeadersInit = {
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key of SSE_HEADERS_PASSTHROUGH) {
|
const headers = getHttpHeaders(req, transportType);
|
||||||
if (req.headers[key] === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = req.headers[key];
|
|
||||||
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
|
console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`);
|
||||||
|
|
||||||
|
|
@ -107,18 +136,7 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
console.log("Connected to SSE transport");
|
console.log("Connected to SSE transport");
|
||||||
return transport;
|
return transport;
|
||||||
} else if (transportType === "streamable-http") {
|
} else if (transportType === "streamable-http") {
|
||||||
const headers: HeadersInit = {
|
const headers = getHttpHeaders(req, transportType);
|
||||||
Accept: "text/event-stream, application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) {
|
|
||||||
if (req.headers[key] === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = req.headers[key];
|
|
||||||
headers[key] = Array.isArray(value) ? value[value.length - 1] : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const transport = new StreamableHTTPClientTransport(
|
const transport = new StreamableHTTPClientTransport(
|
||||||
new URL(query.url as string),
|
new URL(query.url as string),
|
||||||
|
|
@ -137,8 +155,6 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let backingServerTransport: Transport | undefined;
|
|
||||||
|
|
||||||
app.get("/mcp", async (req, res) => {
|
app.get("/mcp", async (req, res) => {
|
||||||
const sessionId = req.headers["mcp-session-id"] as string;
|
const sessionId = req.headers["mcp-session-id"] as string;
|
||||||
console.log(`Received GET message for sessionId ${sessionId}`);
|
console.log(`Received GET message for sessionId ${sessionId}`);
|
||||||
|
|
@ -161,12 +177,12 @@ app.get("/mcp", async (req, res) => {
|
||||||
app.post("/mcp", async (req, res) => {
|
app.post("/mcp", async (req, res) => {
|
||||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||||
console.log(`Received POST message for sessionId ${sessionId}`);
|
console.log(`Received POST message for sessionId ${sessionId}`);
|
||||||
|
let serverTransport: Transport | undefined;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
try {
|
try {
|
||||||
console.log("New streamable-http connection");
|
console.log("New streamable-http connection");
|
||||||
try {
|
try {
|
||||||
await backingServerTransport?.close();
|
serverTransport = await createTransport(req);
|
||||||
backingServerTransport = await createTransport(req);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SseError && error.code === 401) {
|
if (error instanceof SseError && error.code === 401) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -180,12 +196,13 @@ app.post("/mcp", async (req, res) => {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Connected MCP client to backing server transport");
|
console.log("Connected MCP client to server transport");
|
||||||
|
|
||||||
const webAppTransport = new StreamableHTTPServerTransport({
|
const webAppTransport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: randomUUID,
|
sessionIdGenerator: randomUUID,
|
||||||
onsessioninitialized: (sessionId) => {
|
onsessioninitialized: (sessionId) => {
|
||||||
webAppTransports.set(sessionId, webAppTransport);
|
webAppTransports.set(sessionId, webAppTransport);
|
||||||
|
serverTransports.set(sessionId, serverTransport!);
|
||||||
console.log("Created streamable web app transport " + sessionId);
|
console.log("Created streamable web app transport " + sessionId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -194,7 +211,7 @@ app.post("/mcp", async (req, res) => {
|
||||||
|
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
transportToClient: webAppTransport,
|
transportToClient: webAppTransport,
|
||||||
transportToServer: backingServerTransport,
|
transportToServer: serverTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
await (webAppTransport as StreamableHTTPServerTransport).handleRequest(
|
await (webAppTransport as StreamableHTTPServerTransport).handleRequest(
|
||||||
|
|
@ -226,13 +243,39 @@ app.post("/mcp", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete("/mcp", async (req, res) => {
|
||||||
|
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||||
|
console.log(`Received DELETE message for sessionId ${sessionId}`);
|
||||||
|
let serverTransport: Transport | undefined;
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
serverTransport = serverTransports.get(
|
||||||
|
sessionId,
|
||||||
|
) as StreamableHTTPClientTransport;
|
||||||
|
if (!serverTransport) {
|
||||||
|
res.status(404).end("Transport not found for sessionId " + sessionId);
|
||||||
|
} else {
|
||||||
|
await (
|
||||||
|
serverTransport as StreamableHTTPClientTransport
|
||||||
|
).terminateSession();
|
||||||
|
webAppTransports.delete(sessionId);
|
||||||
|
serverTransports.delete(sessionId);
|
||||||
|
console.log(`Transports removed for sessionId ${sessionId}`);
|
||||||
|
}
|
||||||
|
res.status(200).end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in /mcp route:", error);
|
||||||
|
res.status(500).json(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/stdio", async (req, res) => {
|
app.get("/stdio", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log("New connection");
|
console.log("New connection");
|
||||||
|
let serverTransport: Transport | undefined;
|
||||||
try {
|
try {
|
||||||
await backingServerTransport?.close();
|
serverTransport = await createTransport(req);
|
||||||
backingServerTransport = await createTransport(req);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SseError && error.code === 401) {
|
if (error instanceof SseError && error.code === 401) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -250,26 +293,24 @@ app.get("/stdio", async (req, res) => {
|
||||||
|
|
||||||
const webAppTransport = new SSEServerTransport("/message", res);
|
const webAppTransport = new SSEServerTransport("/message", res);
|
||||||
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
||||||
|
serverTransports.set(webAppTransport.sessionId, serverTransport);
|
||||||
console.log("Created web app transport");
|
console.log("Created client/server transports");
|
||||||
|
|
||||||
await webAppTransport.start();
|
await webAppTransport.start();
|
||||||
(backingServerTransport as StdioClientTransport).stderr!.on(
|
|
||||||
"data",
|
(serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => {
|
||||||
(chunk) => {
|
webAppTransport.send({
|
||||||
webAppTransport.send({
|
jsonrpc: "2.0",
|
||||||
jsonrpc: "2.0",
|
method: "notifications/stderr",
|
||||||
method: "notifications/stderr",
|
params: {
|
||||||
params: {
|
content: chunk.toString(),
|
||||||
content: chunk.toString(),
|
},
|
||||||
},
|
});
|
||||||
});
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
transportToClient: webAppTransport,
|
transportToClient: webAppTransport,
|
||||||
transportToServer: backingServerTransport,
|
transportToServer: serverTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Set up MCP proxy");
|
console.log("Set up MCP proxy");
|
||||||
|
|
@ -284,10 +325,9 @@ app.get("/sse", async (req, res) => {
|
||||||
console.log(
|
console.log(
|
||||||
"New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http",
|
"New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http",
|
||||||
);
|
);
|
||||||
|
let serverTransport: Transport | undefined;
|
||||||
try {
|
try {
|
||||||
await backingServerTransport?.close();
|
serverTransport = await createTransport(req);
|
||||||
backingServerTransport = await createTransport(req);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SseError && error.code === 401) {
|
if (error instanceof SseError && error.code === 401) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -305,13 +345,15 @@ app.get("/sse", async (req, res) => {
|
||||||
|
|
||||||
const webAppTransport = new SSEServerTransport("/message", res);
|
const webAppTransport = new SSEServerTransport("/message", res);
|
||||||
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
webAppTransports.set(webAppTransport.sessionId, webAppTransport);
|
||||||
console.log("Created web app transport");
|
console.log("Created client transport");
|
||||||
|
serverTransports.set(webAppTransport.sessionId, serverTransport);
|
||||||
|
console.log("Created server transport");
|
||||||
|
|
||||||
await webAppTransport.start();
|
await webAppTransport.start();
|
||||||
|
|
||||||
mcpProxy({
|
mcpProxy({
|
||||||
transportToClient: webAppTransport,
|
transportToClient: webAppTransport,
|
||||||
transportToServer: backingServerTransport,
|
transportToServer: serverTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Set up MCP proxy");
|
console.log("Set up MCP proxy");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue