Resolve merge conflicts in DynamicJsonForm.tsx and ToolsTab.tsx

- Merged JSON validation functionality from PR branch with enhanced form features from main
- Preserved validateJson method and DynamicJsonFormRef interface for tool validation
- Added support for oneOf schemas, enum dropdowns, and enhanced string field types
- Integrated meta display functionality and resource content props
- Maintained JSON validation before tool execution to prevent invalid data submission
This commit is contained in:
olaservo 2025-08-05 05:48:04 -07:00
commit 8ce1bbd346
42 changed files with 3238 additions and 582 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.16.0",
"version": "0.16.2",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -21,7 +21,7 @@
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.1",
"@modelcontextprotocol/sdk": "^1.17.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

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,26 @@ async function runWebClient(args: Args): Promise<void> {
abort.abort();
});
let server: ReturnType<typeof spawnPromise>;
let serverOk: unknown;
// Build arguments to pass to start.js
const startArgs: string[] = [];
// Pass environment variables
for (const [key, value] of Object.entries(args.envArgs)) {
startArgs.push("-e", `${key}=${value}`);
}
// Pass command and args (using -- to separate them)
if (args.command) {
startArgs.push("--", args.command, ...args.args);
}
try {
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, ...startArgs], {
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
@ -275,7 +246,7 @@ async function main(): Promise<void> {
const args = parseArgs();
if (args.cli) {
runCli(args);
await runCli(args);
} else {
await runWebClient(args);
}

View file

@ -18,6 +18,12 @@ export async function connect(
): Promise<void> {
try {
await client.connect(transport);
if (client.getServerCapabilities()?.logging) {
// default logging level is undefined in the spec, but the user of the
// inspector most likely wants debug.
await client.setLoggingLevel("debug");
}
} catch (error) {
throw new Error(
`Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`,

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,
@ -91,15 +91,17 @@ async function startProdServer(serverOptions) {
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
...(command ? [`--command=${command}`] : []),
...(mcpServerArgs && mcpServerArgs.length > 0
? [`--args=${mcpServerArgs.join(" ")}`]
: []),
],
{
env: {
...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 +249,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

@ -0,0 +1,16 @@
import { test, expect } from "@playwright/test";
// Adjust the URL if your dev server runs on a different port
const APP_URL = "http://localhost:6274/";
test.describe("Startup State", () => {
test("should not navigate to a tab when Inspector first opens", async ({
page,
}) => {
await page.goto(APP_URL);
// Check that there is no hash fragment in the URL
const url = page.url();
expect(url).not.toContain("#");
});
});

View file

@ -32,4 +32,5 @@ module.exports = {
"/e2e/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
randomize: true,
};

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.0",
"version": "0.16.2",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@ -25,9 +25,8 @@
"cleanup:e2e": "node e2e/global-teardown.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.1",
"@modelcontextprotocol/sdk": "^1.17.0",
"@radix-ui/react-checkbox": "^1.1.4",
"ajv": "^6.12.6",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
@ -37,6 +36,7 @@
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"ajv": "^6.12.6",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
@ -49,19 +49,19 @@
"serve-handler": "^6.1.6",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5",
"@types/node": "^22.17.0",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.3.10",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.20",
"co": "^4.6.0",
"eslint": "^9.11.1",
@ -70,11 +70,11 @@
"globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.13",
"ts-jest": "^29.2.6",
"ts-jest": "^29.4.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^6.3.0"
"typescript-eslint": "^8.38.0",
"vite": "^6.3.5"
}
}

View file

@ -71,6 +71,10 @@ import {
initializeInspectorConfig,
saveInspectorConfig,
} from "./utils/configUtils";
import ElicitationTab, {
PendingElicitationRequest,
ElicitationResponse,
} from "./components/ElicitationTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -80,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[]>([]);
@ -116,6 +123,14 @@ const App = () => {
return localStorage.getItem("lastHeaderName") || "";
});
const [oauthClientId, setOauthClientId] = useState<string>(() => {
return localStorage.getItem("lastOauthClientId") || "";
});
const [oauthScope, setOauthScope] = useState<string>(() => {
return localStorage.getItem("lastOauthScope") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
@ -124,13 +139,19 @@ const App = () => {
}
>
>([]);
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
Array<
PendingElicitationRequest & {
resolve: (response: ElicitationResponse) => void;
decline: (error: Error) => void;
}
>
>([]);
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
// Auth debugger state
const [authState, setAuthState] =
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
// Helper function to update specific auth state properties
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
setAuthState((prev) => ({ ...prev, ...updates }));
};
@ -158,6 +179,19 @@ const App = () => {
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const [activeTab, setActiveTab] = useState<string>(() => {
const hash = window.location.hash.slice(1);
const initialTab = hash || "resources";
return initialTab;
});
const currentTabRef = useRef<string>(activeTab);
const lastToolCallOriginTabRef = useRef<string>(activeTab);
useEffect(() => {
currentTabRef.current = activeTab;
}, [activeTab]);
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
const {
width: sidebarWidth,
@ -184,6 +218,8 @@ const App = () => {
env,
bearerToken,
headerName,
oauthClientId,
oauthScope,
config,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
@ -200,9 +236,65 @@ const App = () => {
{ id: nextRequestId.current++, request, resolve, reject },
]);
},
onElicitationRequest: (request, resolve) => {
const currentTab = lastToolCallOriginTabRef.current;
setPendingElicitationRequests((prev) => [
...prev,
{
id: nextRequestId.current++,
request: {
id: nextRequestId.current,
message: request.params.message,
requestedSchema: request.params.requestedSchema,
},
originatingTab: currentTab,
resolve,
decline: (error: Error) => {
console.error("Elicitation request rejected:", error);
},
},
]);
setActiveTab("elicitations");
window.location.hash = "elicitations";
},
getRoots: () => rootsRef.current,
defaultLoggingLevel: logLevel,
});
useEffect(() => {
if (serverCapabilities) {
const hash = window.location.hash.slice(1);
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
"ping",
"sampling",
"elicitations",
"roots",
"auth",
];
const isValidTab = validTabs.includes(hash);
if (!isValidTab) {
const defaultTab = serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
setActiveTab(defaultTab);
window.location.hash = defaultTab;
}
}
}, [serverCapabilities]);
useEffect(() => {
localStorage.setItem("lastCommand", command);
}, [command]);
@ -227,11 +319,18 @@ const App = () => {
localStorage.setItem("lastHeaderName", headerName);
}, [headerName]);
useEffect(() => {
localStorage.setItem("lastOauthClientId", oauthClientId);
}, [oauthClientId]);
useEffect(() => {
localStorage.setItem("lastOauthScope", oauthScope);
}, [oauthScope]);
useEffect(() => {
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
// Auto-connect to previously saved serverURL after OAuth callback
const onOAuthConnect = useCallback(
(serverUrl: string) => {
setSseUrl(serverUrl);
@ -241,7 +340,6 @@ const App = () => {
[connectMcpServer],
);
// Update OAuth debug state during debug callback
const onOAuthDebugConnect = useCallback(
async ({
authorizationCode,
@ -262,7 +360,6 @@ const App = () => {
}
if (restoredState && authorizationCode) {
// Restore the previous auth state and continue the OAuth flow
let currentState: AuthDebuggerState = {
...restoredState,
authorizationCode,
@ -273,12 +370,10 @@ const App = () => {
};
try {
// Create a new state machine instance to continue the flow
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
currentState = { ...currentState, ...updates };
});
// Continue stepping through the OAuth flow from where we left off
while (
currentState.oauthStep !== "complete" &&
currentState.oauthStep !== "authorization_code"
@ -287,7 +382,6 @@ const App = () => {
}
if (currentState.oauthStep === "complete") {
// After the flow completes or reaches a user-input step, update the app state
updateAuthState({
...currentState,
statusMessage: {
@ -310,7 +404,6 @@ const App = () => {
});
}
} else if (authorizationCode) {
// Fallback to the original behavior if no state was restored
updateAuthState({
authorizationCode,
oauthStep: "token_request",
@ -320,7 +413,6 @@ const App = () => {
[sseUrl],
);
// Load OAuth tokens when sseUrl changes
useEffect(() => {
const loadOAuthTokens = async () => {
try {
@ -374,10 +466,36 @@ const App = () => {
}, [roots]);
useEffect(() => {
if (!window.location.hash) {
window.location.hash = "resources";
if (mcpClient && !window.location.hash) {
const defaultTab = serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
window.location.hash = defaultTab;
} else if (!mcpClient && window.location.hash) {
// Clear hash when disconnected - completely remove the fragment
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
}, []);
}, [mcpClient, serverCapabilities]);
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash && hash !== activeTab) {
setActiveTab(hash);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [activeTab]);
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
setPendingSampleRequests((prev) => {
@ -395,6 +513,44 @@ const App = () => {
});
};
const handleResolveElicitation = (
id: number,
response: ElicitationResponse,
) => {
setPendingElicitationRequests((prev) => {
const request = prev.find((r) => r.id === id);
if (request) {
request.resolve(response);
if (request.originatingTab) {
const originatingTab = request.originatingTab;
const validTabs = [
...(serverCapabilities?.resources ? ["resources"] : []),
...(serverCapabilities?.prompts ? ["prompts"] : []),
...(serverCapabilities?.tools ? ["tools"] : []),
"ping",
"sampling",
"elicitations",
"roots",
"auth",
];
if (validTabs.includes(originatingTab)) {
setActiveTab(originatingTab);
window.location.hash = originatingTab;
setTimeout(() => {
setActiveTab(originatingTab);
window.location.hash = originatingTab;
}, 100);
}
}
}
return prev.filter((r) => r.id !== id);
});
};
const clearError = (tabKey: keyof typeof errors) => {
setErrors((prev) => ({ ...prev, [tabKey]: null }));
};
@ -452,7 +608,23 @@ const App = () => {
setNextResourceTemplateCursor(response.nextCursor);
};
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
const response = await sendMCPRequest(
{
method: "prompts/get" as const,
params: { name, arguments: args },
},
GetPromptResultSchema,
"prompts",
);
setPromptContent(JSON.stringify(response, null, 2));
};
const readResource = async (uri: string) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
const response = await sendMCPRequest(
{
method: "resources/read" as const,
@ -461,7 +633,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) => {
@ -509,18 +686,6 @@ const App = () => {
setNextPromptCursor(response.nextCursor);
};
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
const response = await sendMCPRequest(
{
method: "prompts/get" as const,
params: { name, arguments: args },
},
GetPromptResultSchema,
"prompts",
);
setPromptContent(JSON.stringify(response, null, 2));
};
const listTools = async () => {
const response = await sendMCPRequest(
{
@ -532,11 +697,12 @@ const App = () => {
);
setTools(response.tools);
setNextToolCursor(response.nextCursor);
// Cache output schemas for validation
cacheToolOutputSchemas(response.tools);
};
const callTool = async (name: string, params: Record<string, unknown>) => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
const response = await sendMCPRequest(
{
@ -552,6 +718,7 @@ const App = () => {
CompatibilityCallToolResultSchema,
"tools",
);
setToolResult(response);
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
@ -586,7 +753,6 @@ const App = () => {
setStdErrNotifications([]);
};
// Helper component for rendering the AuthDebugger
const AuthDebuggerWrapper = () => (
<TabsContent value="auth">
<AuthDebugger
@ -598,7 +764,6 @@ const App = () => {
</TabsContent>
);
// Helper function to render OAuth callback components
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
@ -650,6 +815,10 @@ const App = () => {
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
@ -658,7 +827,6 @@ const App = () => {
loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/>
{/* Drag handle for resizing sidebar */}
<div
onMouseDown={handleSidebarDragStart}
style={{
@ -679,21 +847,12 @@ const App = () => {
<div className="flex-1 overflow-auto">
{mcpClient ? (
<Tabs
defaultValue={
Object.keys(serverCapabilities ?? {}).includes(
window.location.hash.slice(1),
)
? window.location.hash.slice(1)
: serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping"
}
value={activeTab}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
onValueChange={(value) => {
setActiveTab(value);
window.location.hash = value;
}}
>
<TabsList className="mb-4 py-0">
<TabsTrigger
@ -730,6 +889,15 @@ const App = () => {
</span>
)}
</TabsTrigger>
<TabsTrigger value="elicitations" className="relative">
<MessageSquare className="w-4 h-4 mr-2" />
Elicitations
{pendingElicitationRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingElicitationRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="roots">
<FolderTree className="w-4 h-4 mr-2" />
Roots
@ -846,7 +1014,6 @@ const App = () => {
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
// Clear cached output schemas
cacheToolOutputSchemas([]);
}}
callTool={async (name, params) => {
@ -863,6 +1030,11 @@ const App = () => {
toolResult={toolResult}
nextCursor={nextToolCursor}
error={errors.tools}
resourceContent={resourceContentMap}
onReadResource={(uri: string) => {
clearError("resources");
readResource(uri);
}}
/>
<ConsoleTab />
<PingTab
@ -880,6 +1052,10 @@ const App = () => {
onApprove={handleApproveSampling}
onReject={handleRejectSampling}
/>
<ElicitationTab
pendingRequests={pendingElicitationRequests}
onResolve={handleResolveElicitation}
/>
<RootsTab
roots={roots}
setRoots={setRoots}

View file

@ -0,0 +1,159 @@
import { render, waitFor } from "@testing-library/react";
import App from "../App";
import { useConnection } from "../lib/hooks/useConnection";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
// Mock auth dependencies first
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
}));
jest.mock("../lib/oauth-state-machine", () => ({
OAuthStateMachine: jest.fn(),
}));
jest.mock("../lib/auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue(null),
clear: jest.fn(),
})),
DebugInspectorOAuthClientProvider: jest.fn(),
}));
// Mock the config utils
jest.mock("../utils/configUtils", () => ({
...jest.requireActual("../utils/configUtils"),
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
getMCPProxyAuthToken: jest.fn(() => ({
token: "",
header: "X-MCP-Proxy-Auth",
})),
getInitialTransportType: jest.fn(() => "stdio"),
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
getInitialCommand: jest.fn(() => "mcp-server-everything"),
getInitialArgs: jest.fn(() => ""),
initializeInspectorConfig: jest.fn(() => ({})),
saveInspectorConfig: jest.fn(),
}));
// Default connection state is disconnected
const disconnectedConnectionState = {
connectionStatus: "disconnected" as const,
serverCapabilities: null,
mcpClient: null,
requestHistory: [],
makeRequest: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
};
// Connected state for tests that need an active connection
const connectedConnectionState = {
...disconnectedConnectionState,
connectionStatus: "connected" as const,
serverCapabilities: {},
mcpClient: {
request: jest.fn(),
notification: jest.fn(),
close: jest.fn(),
} as unknown as Client,
};
// Mock required dependencies, but unrelated to routing.
jest.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: () => ({
height: 300,
handleDragStart: jest.fn(),
}),
useDraggableSidebar: () => ({
width: 320,
isDragging: false,
handleDragStart: jest.fn(),
}),
}));
jest.mock("../components/Sidebar", () => ({
__esModule: true,
default: () => <div>Sidebar</div>,
}));
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
// Use an empty module mock, so that mock state can be reset between tests.
jest.mock("../lib/hooks/useConnection", () => ({
useConnection: jest.fn(),
}));
describe("App - URL Fragment Routing", () => {
const mockUseConnection = jest.mocked(useConnection);
beforeEach(() => {
jest.restoreAllMocks();
// Inspector starts disconnected.
mockUseConnection.mockReturnValue(disconnectedConnectionState);
});
test("does not set hash when starting disconnected", async () => {
render(<App />);
await waitFor(() => {
expect(window.location.hash).toBe("");
});
});
test("sets default hash based on server capabilities priority", async () => {
// Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth
//
// Server capabilities determine the first three tabs; if none are present, falls back to Ping.
const testCases = [
{
capabilities: { resources: { listChanged: true, subscribe: true } },
expected: "#resources",
},
{
capabilities: { prompts: { listChanged: true, subscribe: true } },
expected: "#prompts",
},
{
capabilities: { tools: { listChanged: true, subscribe: true } },
expected: "#tools",
},
{ capabilities: {}, expected: "#ping" },
];
const { rerender } = render(<App />);
for (const { capabilities, expected } of testCases) {
window.location.hash = "";
mockUseConnection.mockReturnValue({
...connectedConnectionState,
serverCapabilities: capabilities,
});
rerender(<App />);
await waitFor(() => {
expect(window.location.hash).toBe(expected);
});
}
});
test("clears hash when disconnected", async () => {
// Start with a hash set (simulating a connection)
window.location.hash = "#resources";
// App starts disconnected (default mock)
render(<App />);
// Should clear the hash when disconnected
await waitFor(() => {
expect(window.location.hash).toBe("");
});
});
});

View file

@ -28,10 +28,10 @@ export interface DynamicJsonFormRef {
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (supportedTypes.includes(schema.type)) return true;
if (schema.type && supportedTypes.includes(schema.type)) return true;
if (schema.type === "object") {
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
return Object.values(schema.properties ?? {}).every(
(prop) => prop.type && supportedTypes.includes(prop.type),
);
}
if (schema.type === "array") {
@ -236,10 +236,89 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
parentSchema?.required?.includes(propertyName || "") ?? false;
switch (propSchema.type) {
case "string":
case "string": {
if (
propSchema.oneOf &&
propSchema.oneOf.every(
(option) =>
typeof option.const === "string" &&
typeof option.title === "string",
)
) {
return (
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{propSchema.oneOf.map((option) => (
<option
key={option.const as string}
value={option.const as string}
>
{option.title as string}
</option>
))}
</select>
);
}
if (propSchema.enum) {
return (
<select
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
if (!val && !isRequired) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
required={isRequired}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
>
<option value="">Select an option...</option>
{propSchema.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
let inputType = "text";
switch (propSchema.format) {
case "email":
inputType = "email";
break;
case "uri":
inputType = "url";
break;
case "date":
inputType = "date";
break;
case "date-time":
inputType = "datetime-local";
break;
default:
inputType = "text";
break;
}
return (
<Input
type="text"
type={inputType}
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
@ -248,8 +327,13 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
}}
placeholder={propSchema.description}
required={isRequired}
minLength={propSchema.minLength}
maxLength={propSchema.maxLength}
pattern={propSchema.pattern}
/>
);
}
case "number":
return (
<Input
@ -268,8 +352,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}
max={propSchema.maximum}
/>
);
case "integer":
return (
<Input
@ -282,7 +369,6 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
handleFieldChange(path, undefined);
} else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
@ -290,8 +376,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
}}
placeholder={propSchema.description}
required={isRequired}
min={propSchema.minimum}
max={propSchema.maximum}
/>
);
case "boolean":
return (
<Input

View file

@ -0,0 +1,173 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import DynamicJsonForm from "./DynamicJsonForm";
import JsonView from "./JsonView";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
PendingElicitationRequest,
ElicitationResponse,
} from "./ElicitationTab";
import Ajv from "ajv";
export type ElicitationRequestProps = {
request: PendingElicitationRequest;
onResolve: (id: number, response: ElicitationResponse) => void;
};
const ElicitationRequest = ({
request,
onResolve,
}: ElicitationRequestProps) => {
const [formData, setFormData] = useState<JsonValue>({});
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
const defaultValue = generateDefaultValue(request.request.requestedSchema);
setFormData(defaultValue);
setValidationError(null);
}, [request.request.requestedSchema]);
const validateEmailFormat = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validateFormData = (
data: JsonValue,
schema: JsonSchemaType,
): boolean => {
if (
schema.type === "object" &&
schema.properties &&
typeof data === "object" &&
data !== null
) {
const dataObj = data as Record<string, unknown>;
if (Array.isArray(schema.required)) {
for (const field of schema.required) {
const value = dataObj[field];
if (value === undefined || value === null || value === "") {
setValidationError(`Required field missing: ${field}`);
return false;
}
}
}
for (const [fieldName, fieldValue] of Object.entries(dataObj)) {
const fieldSchema = schema.properties[fieldName];
if (
fieldSchema &&
fieldSchema.format === "email" &&
typeof fieldValue === "string"
) {
if (!validateEmailFormat(fieldValue)) {
setValidationError(`Invalid email format: ${fieldName}`);
return false;
}
}
}
}
return true;
};
const handleAccept = () => {
try {
if (!validateFormData(formData, request.request.requestedSchema)) {
return;
}
const ajv = new Ajv();
const validate = ajv.compile(request.request.requestedSchema);
const isValid = validate(formData);
if (!isValid) {
const errorMessage = ajv.errorsText(validate.errors);
setValidationError(errorMessage);
return;
}
onResolve(request.id, {
action: "accept",
content: formData as Record<string, unknown>,
});
} catch (error) {
setValidationError(
error instanceof Error ? error.message : "Validation failed",
);
}
};
const handleDecline = () => {
onResolve(request.id, { action: "decline" });
};
const handleCancel = () => {
onResolve(request.id, { action: "cancel" });
};
const schemaTitle =
request.request.requestedSchema.title || "Information Request";
const schemaDescription = request.request.requestedSchema.description;
return (
<div
data-testid="elicitation-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<h4 className="font-semibold">{schemaTitle}</h4>
<p className="text-sm">{request.request.message}</p>
{schemaDescription && (
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
)}
<div className="mt-2">
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
<JsonView
data={JSON.stringify(request.request.requestedSchema, null, 2)}
/>
</div>
</div>
</div>
<div className="flex-1 space-y-4">
<div className="space-y-2">
<h4 className="font-medium">Response Form</h4>
<DynamicJsonForm
schema={request.request.requestedSchema}
value={formData}
onChange={(newValue: JsonValue) => {
setFormData(newValue);
setValidationError(null);
}}
/>
{validationError && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="text-sm text-red-600 dark:text-red-400">
<strong>Validation Error:</strong> {validationError}
</div>
</div>
)}
</div>
<div className="flex space-x-2 mt-1">
<Button type="button" onClick={handleAccept}>
Submit
</Button>
<Button type="button" variant="outline" onClick={handleDecline}>
Decline
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
</div>
</div>
</div>
);
};
export default ElicitationRequest;

View file

@ -0,0 +1,56 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { TabsContent } from "@/components/ui/tabs";
import { JsonSchemaType } from "@/utils/jsonUtils";
import ElicitationRequest from "./ElicitationRequest";
export interface ElicitationRequestData {
id: number;
message: string;
requestedSchema: JsonSchemaType;
}
export interface ElicitationResponse {
action: "accept" | "decline" | "cancel";
content?: Record<string, unknown>;
}
export type PendingElicitationRequest = {
id: number;
request: ElicitationRequestData;
originatingTab?: string;
};
export type Props = {
pendingRequests: PendingElicitationRequest[];
onResolve: (id: number, response: ElicitationResponse) => void;
};
const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
return (
<TabsContent value="elicitations">
<div className="h-96">
<Alert>
<AlertDescription>
When the server requests information from the user, requests will
appear here for response.
</AlertDescription>
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<ElicitationRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
))}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
</div>
</div>
</TabsContent>
);
};
export default ElicitationTab;

View file

@ -48,6 +48,7 @@ const PromptsTab = ({
ref: PromptReference | ResourceReference,
argName: string,
value: string,
context?: Record<string, string>,
) => Promise<string[]>;
completionsSupported: boolean;
promptContent: string;
@ -73,6 +74,7 @@ const PromptsTab = ({
},
argName,
value,
promptArgs,
);
}
};

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