Merge branch 'main' into feature/elicitations
This commit is contained in:
commit
144755b369
26 changed files with 934 additions and 160 deletions
64
.github/workflows/claude.yml
vendored
Normal file
64
.github/workflows/claude.yml
vendored
Normal 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
41
.github/workflows/cli_tests.yml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
155
client/src/components/HistoryAndNotifications.tsx
Normal file
155
client/src/components/HistoryAndNotifications.tsx
Normal 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;
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
112
client/src/components/ResourceLinkView.tsx
Normal file
112
client/src/components/ResourceLinkView.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
226
client/src/components/__tests__/HistoryAndNotifications.test.tsx
Normal file
226
client/src/components/__tests__/HistoryAndNotifications.test.tsx
Normal 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
Loading…
Reference in a new issue