Merge branch 'main' into feature/elicitations

This commit is contained in:
Ola Hungerford 2025-07-23 21:50:48 -07:00 • committed by GitHub
commit 144755b369
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 934 additions and 160 deletions

64
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,64 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
) &&
(
github.actor == 'ihrpr' ||
github.actor == 'olaservo'
)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

41
.github/workflows/cli_tests.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: CLI Tests
on:
push:
paths:
- "cli/**"
pull_request:
paths:
- "cli/**"
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./cli
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: npm
- name: Install dependencies
run: |
cd ..
npm ci --ignore-scripts
- name: Build CLI
run: npm run build
- name: Explicitly pre-install test dependencies
run: npx -y @modelcontextprotocol/server-everything --help || true
- name: Run tests
run: npm test
env:
NPM_CONFIG_YES: true
CI: true

3
.gitignore vendored
View file

@ -9,7 +9,8 @@ client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo
cli/build
test-output
# symlinked by `npm run link:sdk`:
sdk
client/playwright-report/
client/results.json
client/test-results/

View file

@ -166,6 +166,12 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER
DANGEROUSLY_OMIT_AUTH=true npm start
```
You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server:
```bash
MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) npm start
```
#### Local-only Binding
By default, both the MCP Inspector proxy server and client bind only to `localhost` to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable:
@ -254,6 +260,12 @@ Development mode:
```bash
npm run dev
# To co-develop with the typescript-sdk package (assuming it's cloned in ../typescript-sdk; set MCP_SDK otherwise):
npm run dev:sdk "cd sdk && npm run examples:simple-server:w"
# then open http://localhost:3000/mcp as SHTTP in the inspector.
# To go back to the deployed SDK version:
# npm run unlink:sdk && npm i
```
> **Note for Windows users:**
@ -303,7 +315,7 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/l
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
# Connect to a remote MCP server (with Streamable HTTP transport)
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list
# Call a tool on a remote server
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.15.0",
"version": "0.16.1",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -12,7 +12,7 @@ const colors = {
import fs from "fs";
import path from "path";
import { execSync, spawn } from "child_process";
import { spawn } from "child_process";
import os from "os";
import { fileURLToPath } from "url";
@ -680,7 +680,7 @@ async function runTests() {
// Test 26: HTTP transport with explicit --transport http flag
await runBasicTest(
"http_transport_with_explicit_flag",
"http://127.0.0.1:3001",
"http://127.0.0.1:3001/mcp",
"--transport",
"http",
"--cli",
@ -710,6 +710,26 @@ async function runTests() {
"tools/list",
);
// Test 29: HTTP transport without URL (should fail)
await runErrorTest(
"http_transport_without_url",
"--transport",
"http",
"--cli",
"--method",
"tools/list",
);
// Test 30: SSE transport without URL (should fail)
await runErrorTest(
"sse_transport_without_url",
"--transport",
"sse",
"--cli",
"--method",
"tools/list",
);
// Kill HTTP server
try {
process.kill(-httpServer.pid);

View file

@ -50,28 +50,15 @@ function delay(ms: number): Promise<void> {
}
async function runWebClient(args: Args): Promise<void> {
const inspectorServerPath = resolve(
__dirname,
"../../",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"../../",
"client",
"bin",
"client.js",
"start.js",
);
const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
const abort = new AbortController();
let cancelled: boolean = false;
process.on("SIGINT", () => {
@ -79,42 +66,13 @@ async function runWebClient(args: Args): Promise<void> {
abort.abort();
});
let server: ReturnType<typeof spawnPromise>;
let serverOk: unknown;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
...(args.command ? [`--env`, args.command] : []),
...(args.args ? [`--args=${args.args.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(args.envArgs),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
} catch (error) {}
if (serverOk) {
try {
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
await spawnPromise("node", [inspectorClientPath], {
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
@ -275,7 +233,7 @@ async function main(): Promise<void> {
const args = parseArgs();
if (args.cli) {
runCli(args);
await runCli(args);
} else {
await runWebClient(args);
}

View file

@ -287,6 +287,8 @@ async function main(): Promise<void> {
try {
const args = parseArgs();
await callMethod(args);
// Explicitly exit to ensure process terminates in CI
process.exit(0);
} catch (error) {
handleError(error);
}

View file

@ -14,24 +14,6 @@ export type TransportOptions = {
url?: string;
};
function createSSETransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? "");
const sseUrl = baseUrl.pathname.endsWith("/sse")
? baseUrl
: new URL("/sse", baseUrl);
return new SSEClientTransport(sseUrl);
}
function createHTTPTransport(options: TransportOptions): Transport {
const baseUrl = new URL(options.url ?? "");
const mcpUrl = baseUrl.pathname.endsWith("/mcp")
? baseUrl
: new URL("/mcp", baseUrl);
return new StreamableHTTPClientTransport(mcpUrl);
}
function createStdioTransport(options: TransportOptions): Transport {
let args: string[] = [];
@ -75,12 +57,18 @@ export function createTransport(options: TransportOptions): Transport {
return createStdioTransport(options);
}
// If not STDIO, then it must be either SSE or HTTP.
if (!options.url) {
throw new Error("URL must be provided for SSE or HTTP transport types.");
}
const url = new URL(options.url);
if (transportType === "sse") {
return createSSETransport(options);
return new SSEClientTransport(url);
}
if (transportType === "http") {
return createHTTPTransport(options);
return new StreamableHTTPClientTransport(url);
}
throw new Error(`Unsupported transport type: ${transportType}`);

View file

@ -40,7 +40,7 @@ async function startDevServer(serverOptions) {
...process.env,
SERVER_PORT,
CLIENT_PORT,
MCP_PROXY_TOKEN: sessionToken,
MCP_PROXY_AUTH_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
@ -99,7 +99,7 @@ async function startProdServer(serverOptions) {
...process.env,
SERVER_PORT,
CLIENT_PORT,
MCP_PROXY_TOKEN: sessionToken,
MCP_PROXY_AUTH_TOKEN: sessionToken,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
@ -247,8 +247,9 @@ async function main() {
: "Starting MCP inspector...",
);
// Generate session token for authentication
const sessionToken = randomBytes(32).toString("hex");
// Use provided token from environment or generate a new one
const sessionToken =
process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex");
const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH;
const abort = new AbortController();

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.15.0",
"version": "0.16.1",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -52,7 +52,7 @@ import { z } from "zod";
import "./App.css";
import AuthDebugger from "./components/AuthDebugger";
import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History";
import HistoryAndNotifications from "./components/HistoryAndNotifications";
import PingTab from "./components/PingTab";
import PromptsTab, { Prompt } from "./components/PromptsTab";
import ResourcesTab from "./components/ResourcesTab";
@ -84,6 +84,9 @@ const App = () => {
ResourceTemplate[]
>([]);
const [resourceContent, setResourceContent] = useState<string>("");
const [resourceContentMap, setResourceContentMap] = useState<
Record<string, string>
>({});
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [promptContent, setPromptContent] = useState<string>("");
const [tools, setTools] = useState<Tool[]>([]);
@ -597,7 +600,12 @@ const App = () => {
ReadResourceResultSchema,
"resources",
);
setResourceContent(JSON.stringify(response, null, 2));
const content = JSON.stringify(response, null, 2);
setResourceContent(content);
setResourceContentMap((prev) => ({
...prev,
[uri]: content,
}));
};
const subscribeToResource = async (uri: string) => {
@ -985,6 +993,11 @@ const App = () => {
toolResult={toolResult}
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
onReadResource={(uri: string) => {
clearError("resources");
readResource(uri);
}}
/>
<ConsoleTab />
<PingTab

View file

@ -5,6 +5,8 @@ import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import { useToast } from "@/lib/hooks/useToast";
import { CheckCheck, Copy } from "lucide-react";
interface DynamicJsonFormProps {
schema: JsonSchemaType;
@ -60,6 +62,9 @@ const DynamicJsonForm = ({
const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>();
const [copiedJson, setCopiedJson] = useState<boolean>(false);
const { toast } = useToast();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
@ -488,19 +493,64 @@ const DynamicJsonForm = ({
}
}, [shouldUseJsonMode, isJsonMode]);
const handleCopyJson = useCallback(() => {
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(
JSON.stringify(value, null, 2) ?? "[]",
);
setCopiedJson(true);
toast({
title: "JSON copied",
description:
"The JSON data has been successfully copied to your clipboard.",
});
setTimeout(() => {
setCopiedJson(false);
}, 2000);
} catch (error) {
toast({
title: "Error",
description: `Failed to copy JSON: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
};
copyToClipboard();
}, [toast, value]);
return (
<div className="space-y-4">
<div className="flex justify-end space-x-2">
{isJsonMode && (
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON
</Button>
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyJson}
>
{copiedJson ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy JSON
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={formatJson}
>
Format JSON
</Button>
</>
)}
{!isOnlyJSON && (
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}

View file

@ -0,0 +1,155 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { useState } from "react";
import JsonView from "./JsonView";
const HistoryAndNotifications = ({
requestHistory,
serverNotifications,
}: {
requestHistory: Array<{ request: string; response?: string }>;
serverNotifications: ServerNotification[];
}) => {
const [expandedRequests, setExpandedRequests] = useState<{
[key: number]: boolean;
}>({});
const [expandedNotifications, setExpandedNotifications] = useState<{
[key: number]: boolean;
}>({});
const toggleRequestExpansion = (index: number) => {
setExpandedRequests((prev) => ({ ...prev, [index]: !prev[index] }));
};
const toggleNotificationExpansion = (index: number) => {
setExpandedNotifications((prev) => ({ ...prev, [index]: !prev[index] }));
};
return (
<div className="bg-card overflow-hidden flex h-full">
<div className="flex-1 overflow-y-auto p-4 border-r">
<h2 className="text-lg font-semibold mb-4">History</h2>
{requestHistory.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No history yet
</p>
) : (
<ul className="space-y-3">
{requestHistory
.slice()
.reverse()
.map((request, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() =>
toggleRequestExpansion(requestHistory.length - 1 - index)
}
>
<span className="font-mono">
{requestHistory.length - index}.{" "}
{JSON.parse(request.request).method}
</span>
<span>
{expandedRequests[requestHistory.length - 1 - index]
? "▼"
: "▶"}
</span>
</div>
{expandedRequests[requestHistory.length - 1 - index] && (
<>
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-blue-600">
Request:
</span>
</div>
<JsonView
data={request.request}
className="bg-background"
/>
</div>
{request.response && (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-green-600">
Response:
</span>
</div>
<JsonView
data={request.response}
className="bg-background"
/>
</div>
)}
</>
)}
</li>
))}
</ul>
)}
</div>
<div className="flex-1 overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
{serverNotifications.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
No notifications yet
</p>
) : (
<ul className="space-y-3">
{serverNotifications
.slice()
.reverse()
.map((notification, index) => (
<li
key={index}
className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() =>
toggleNotificationExpansion(
serverNotifications.length - 1 - index,
)
}
>
<span className="font-mono">
{serverNotifications.length - index}.{" "}
{notification.method}
</span>
<span>
{expandedNotifications[
serverNotifications.length - 1 - index
]
? "▼"
: "▶"}
</span>
</div>
{expandedNotifications[
serverNotifications.length - 1 - index
] && (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-purple-600">
Details:
</span>
</div>
<JsonView
data={JSON.stringify(notification, null, 2)}
className="bg-background"
/>
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
};
export default HistoryAndNotifications;

View file

@ -98,12 +98,12 @@ const PromptsTab = ({
setPromptArgs({});
}}
renderItem={(prompt) => (
<>
<div className="flex flex-col items-start">
<span className="flex-1">{prompt.name}</span>
<span className="text-sm text-gray-500">
<span className="text-sm text-gray-500 text-left">
{prompt.description}
</span>
</>
</div>
)}
title="Prompts"
buttonText={nextCursor ? "List More Prompts" : "List Prompts"}

View file

@ -0,0 +1,112 @@
import { useState, useCallback, useMemo, memo } from "react";
import JsonView from "./JsonView";
interface ResourceLinkViewProps {
uri: string;
name?: string;
description?: string;
mimeType?: string;
resourceContent: string;
onReadResource?: (uri: string) => void;
}
const ResourceLinkView = memo(
({
uri,
name,
description,
mimeType,
resourceContent,
onReadResource,
}: ResourceLinkViewProps) => {
const [{ expanded, loading }, setState] = useState({
expanded: false,
loading: false,
});
const expandedContent = useMemo(
() =>
expanded && resourceContent ? (
<div className="mt-2">
<div className="flex justify-between items-center mb-1">
<span className="font-semibold text-green-600">Resource:</span>
</div>
<JsonView data={resourceContent} className="bg-background" />
</div>
) : null,
[expanded, resourceContent],
);
const handleClick = useCallback(() => {
if (!onReadResource) return;
if (!expanded) {
setState((prev) => ({ ...prev, expanded: true, loading: true }));
onReadResource(uri);
setState((prev) => ({ ...prev, loading: false }));
} else {
setState((prev) => ({ ...prev, expanded: false }));
}
}, [expanded, onReadResource, uri]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && onReadResource) {
e.preventDefault();
handleClick();
}
},
[handleClick, onReadResource],
);
return (
<div className="text-sm text-foreground bg-secondary py-2 px-3 rounded">
<div
className="flex justify-between items-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded"
onClick={onReadResource ? handleClick : undefined}
onKeyDown={onReadResource ? handleKeyDown : undefined}
tabIndex={onReadResource ? 0 : -1}
role="button"
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} resource ${uri}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline px-1 py-0.5 break-all font-mono flex-1 min-w-0">
{uri}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{mimeType && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{mimeType}
</span>
)}
{onReadResource && (
<span className="ml-2 flex-shrink-0" aria-hidden="true">
{loading ? (
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
) : (
<span>{expanded ? "▼" : "▶"}</span>
)}
</span>
)}
</div>
</div>
{name && (
<div className="font-semibold text-sm text-gray-900 dark:text-gray-100 mb-1">
{name}
</div>
)}
{description && (
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{description}
</p>
)}
</div>
</div>
{expandedContent}
</div>
);
},
);
export default ResourceLinkView;

View file

@ -1,4 +1,5 @@
import JsonView from "./JsonView";
import ResourceLinkView from "./ResourceLinkView";
import {
CallToolResultSchema,
CompatibilityCallToolResult,
@ -9,6 +10,8 @@ import { validateToolOutput, hasOutputSchema } from "@/utils/schemaUtils";
interface ToolResultsProps {
toolResult: CompatibilityCallToolResult | null;
selectedTool: Tool | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}
const checkContentCompatibility = (
@ -19,49 +22,54 @@ const checkContentCompatibility = (
[key: string]: unknown;
}>,
): { isCompatible: boolean; message: string } => {
if (
unstructuredContent.length !== 1 ||
unstructuredContent[0].type !== "text"
) {
// Look for at least one text content block that matches the structured content
const textBlocks = unstructuredContent.filter(
(block) => block.type === "text",
);
if (textBlocks.length === 0) {
return {
isCompatible: false,
message: "Unstructured content is not a single text block",
message: "No text blocks to match structured content",
};
}
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",
};
// Check if any text block contains JSON that matches the structured content
for (const textBlock of textBlocks) {
const textContent = textBlock.text;
if (!textContent) {
continue;
}
try {
const parsedContent = JSON.parse(textContent);
const isEqual =
JSON.stringify(parsedContent) === JSON.stringify(structuredContent);
if (isEqual) {
return {
isCompatible: true,
message: `Structured content matches text block${textBlocks.length > 1 ? " (multiple blocks)" : ""}${unstructuredContent.length > textBlocks.length ? " + other content" : ""}`,
};
}
} catch {
// Continue to next text block if this one doesn't parse as JSON
continue;
}
} catch {
return {
isCompatible: false,
message: "Unstructured content is not valid JSON",
};
}
return {
isCompatible: false,
message: "No text block matches structured content",
};
};
const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
const ToolResults = ({
toolResult,
selectedTool,
resourceContent,
onReadResource,
}: ToolResultsProps) => {
if (!toolResult) return null;
if ("content" in toolResult) {
@ -200,6 +208,16 @@ const ToolResults = ({ toolResult, selectedTool }: ToolResultsProps) => {
) : (
<JsonView data={item.resource} />
))}
{item.type === "resource_link" && (
<ResourceLinkView
uri={item.uri}
name={item.name}
description={item.description}
mimeType={item.mimeType}
resourceContent={resourceContent[item.uri] || ""}
onReadResource={onReadResource}
/>
)}
</div>
))}
</div>

View file

@ -28,6 +28,8 @@ const ToolsTab = ({
setSelectedTool,
toolResult,
nextCursor,
resourceContent,
onReadResource,
}: {
tools: Tool[];
listTools: () => void;
@ -38,6 +40,8 @@ const ToolsTab = ({
toolResult: CompatibilityCallToolResult | null;
nextCursor: ListToolsResult["nextCursor"];
error: string | null;
resourceContent: Record<string, string>;
onReadResource?: (uri: string) => void;
}) => {
const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false);
@ -267,6 +271,8 @@ const ToolsTab = ({
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}
resourceContent={resourceContent}
onReadResource={onReadResource}
/>
</div>
) : (

View file

@ -559,8 +559,11 @@ describe("DynamicJsonForm Complex Fields", () => {
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
expect(buttons).toHaveLength(2); // Copy JSON + Format JSON
const copyButton = screen.getByRole("button", { name: /copy json/i });
const formatButton = screen.getByRole("button", { name: /format json/i });
expect(copyButton).toBeTruthy();
expect(formatButton).toBeTruthy();
});
it("should pass changed values to onChange", () => {
@ -579,3 +582,72 @@ describe("DynamicJsonForm Complex Fields", () => {
});
});
});
describe("DynamicJsonForm Copy JSON Functionality", () => {
const mockWriteText = jest.fn(() => Promise.resolve());
beforeEach(() => {
jest.clearAllMocks();
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
});
});
const renderFormInJsonMode = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: { nested: "test value" },
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Copy JSON Button", () => {
it("should render Copy JSON button when in JSON mode", () => {
renderFormInJsonMode();
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
expect(copyButton).toBeTruthy();
});
it("should not render Copy JSON button when not in JSON mode", () => {
const simpleSchema = {
type: "string" as const,
description: "Test string field",
};
render(
<DynamicJsonForm
schema={simpleSchema}
value="test"
onChange={jest.fn()}
/>,
);
const copyButton = screen.queryByRole("button", { name: "Copy JSON" });
expect(copyButton).toBeNull();
});
it("should copy JSON to clipboard when clicked", async () => {
const testValue = { nested: "test value", number: 42 };
renderFormInJsonMode({ value: testValue });
const copyButton = screen.getByRole("button", { name: "Copy JSON" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith(
JSON.stringify(testValue, null, 2),
);
});
});
});
});

View file

@ -0,0 +1,226 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import HistoryAndNotifications from "../HistoryAndNotifications";
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
// Mock JsonView component
jest.mock("../JsonView", () => {
return function JsonView({ data }: { data: string }) {
return <div data-testid="json-view">{data}</div>;
};
});
describe("HistoryAndNotifications", () => {
const mockRequestHistory = [
{
request: JSON.stringify({ method: "test/method1", params: {} }),
response: JSON.stringify({ result: "success" }),
},
{
request: JSON.stringify({ method: "test/method2", params: {} }),
response: JSON.stringify({ result: "success" }),
},
];
const mockNotifications: ServerNotification[] = [
{
method: "notifications/message",
params: {
level: "info" as const,
message: "First notification",
},
},
{
method: "notifications/progress",
params: {
progressToken: "test-token",
progress: 50,
message: "Second notification",
},
},
];
it("renders history and notifications sections", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={mockNotifications}
/>,
);
expect(screen.getByText("History")).toBeTruthy();
expect(screen.getByText("Server Notifications")).toBeTruthy();
});
it("displays request history items with correct numbering", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
// Items should be numbered in reverse order (newest first)
expect(screen.getByText("2. test/method2")).toBeTruthy();
expect(screen.getByText("1. test/method1")).toBeTruthy();
});
it("displays server notifications with correct numbering", () => {
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
// Items should be numbered in reverse order (newest first)
expect(screen.getByText("2. notifications/progress")).toBeTruthy();
expect(screen.getByText("1. notifications/message")).toBeTruthy();
});
it("expands and collapses request items when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
const firstRequestHeader = screen.getByText("2. test/method2");
// Initially collapsed - should show ▶ arrows (there are multiple)
expect(screen.getAllByText("▶")).toHaveLength(2);
expect(screen.queryByText("Request:")).toBeNull();
// Click to expand
fireEvent.click(firstRequestHeader);
// Should now be expanded - one ▼ and one ▶
expect(screen.getByText("▼")).toBeTruthy();
expect(screen.getAllByText("▶")).toHaveLength(1);
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
});
it("expands and collapses notification items when clicked", () => {
render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
const firstNotificationHeader = screen.getByText(
"2. notifications/progress",
);
// Initially collapsed
expect(screen.getAllByText("▶")).toHaveLength(2);
expect(screen.queryByText("Details:")).toBeNull();
// Click to expand
fireEvent.click(firstNotificationHeader);
// Should now be expanded
expect(screen.getByText("▼")).toBeTruthy();
expect(screen.getAllByText("▶")).toHaveLength(1);
expect(screen.getByText("Details:")).toBeTruthy();
});
it("maintains expanded state when new notifications are added", () => {
const { rerender } = render(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={mockNotifications}
/>,
);
// Find and expand the older notification (should be "1. notifications/message")
const olderNotificationHeader = screen.getByText(
"1. notifications/message",
);
fireEvent.click(olderNotificationHeader);
// Verify it's expanded
expect(screen.getByText("Details:")).toBeTruthy();
// Add a new notification at the beginning (simulating real behavior)
const newNotifications: ServerNotification[] = [
{
method: "notifications/resources/updated",
params: { uri: "file://test.txt" },
},
...mockNotifications,
];
// Re-render with new notifications
rerender(
<HistoryAndNotifications
requestHistory={[]}
serverNotifications={newNotifications}
/>,
);
// The original notification should still be expanded
// It's now numbered as "2. notifications/message" due to the new item
expect(screen.getByText("3. notifications/progress")).toBeTruthy();
expect(screen.getByText("2. notifications/message")).toBeTruthy();
expect(screen.getByText("1. notifications/resources/updated")).toBeTruthy();
// The originally expanded notification should still show its details
expect(screen.getByText("Details:")).toBeTruthy();
});
it("maintains expanded state when new requests are added", () => {
const { rerender } = render(
<HistoryAndNotifications
requestHistory={mockRequestHistory}
serverNotifications={[]}
/>,
);
// Find and expand the older request (should be "1. test/method1")
const olderRequestHeader = screen.getByText("1. test/method1");
fireEvent.click(olderRequestHeader);
// Verify it's expanded
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
// Add a new request at the beginning
const newRequestHistory = [
{
request: JSON.stringify({ method: "test/new_method", params: {} }),
response: JSON.stringify({ result: "new success" }),
},
...mockRequestHistory,
];
// Re-render with new request history
rerender(
<HistoryAndNotifications
requestHistory={newRequestHistory}
serverNotifications={[]}
/>,
);
// The original request should still be expanded
// It's now numbered as "2. test/method1" due to the new item
expect(screen.getByText("3. test/method2")).toBeTruthy();
expect(screen.getByText("2. test/method1")).toBeTruthy();
expect(screen.getByText("1. test/new_method")).toBeTruthy();
// The originally expanded request should still show its details
expect(screen.getByText("Request:")).toBeTruthy();
expect(screen.getByText("Response:")).toBeTruthy();
});
it("displays empty state messages when no data is available", () => {
render(
<HistoryAndNotifications requestHistory={[]} serverNotifications={[]} />,
);
expect(screen.getByText("No history yet")).toBeTruthy();
expect(screen.getByText("No notifications yet")).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show more