Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85abfd4a28 |
||
|
|
0681500e8f |
||
|
|
d3085247ce |
||
|
|
8f0c98cace |
||
|
|
cc416f0f93 |
||
|
|
af79614c81 |
||
|
|
c4898540be |
||
|
|
95fa9a1c58 | ||
|
|
c7dc8f707a | ||
|
|
fb4a753cd0 |
||
|
|
923c58edec | ||
|
|
0e1bc1d402 | ||
|
|
47f92841bc |
||
|
|
4f3e7fa167 |
||
|
|
9d74acf526 |
||
|
|
3188ee3a20 | ||
|
|
9140789869 |
21 changed files with 1230 additions and 595 deletions
8
.github/workflows/e2e_tests.yml
vendored
8
.github/workflows/e2e_tests.yml
vendored
|
|
@ -1,10 +1,14 @@
|
||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
|
|
||||||
|
# Pinned to `v1/main`, this branch's own base. These filters read `main`
|
||||||
|
# historically, from when the v1 line was itself `main` — which meant they
|
||||||
|
# matched nothing here and the suite silently never ran on this branch.
|
||||||
|
# See #2065.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [v1/main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [v1/main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
|
||||||
26
.github/workflows/main.yml
vendored
26
.github/workflows/main.yml
vendored
|
|
@ -1,9 +1,18 @@
|
||||||
|
# `v1/main` is the deprecated maintenance line: it publishes straight from this
|
||||||
|
# branch to the `v1-latest` dist-tag and never merges into `main`, which now
|
||||||
|
# carries v2. Keep these filters pinned to `v1/main` — a stale `main` entry here
|
||||||
|
# describes a topology that no longer exists, and would start matching the moment
|
||||||
|
# anything put a v1-shaped tree back on that branch.
|
||||||
|
#
|
||||||
|
# (For `push`, GitHub runs the workflow file from the pushed commit, so this file
|
||||||
|
# is only ever consulted for `v1/main` regardless. The filter states intent.)
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- v1/main
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- v1/main
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
|
|
@ -37,6 +46,10 @@ jobs:
|
||||||
working-directory: ./client
|
working-directory: ./client
|
||||||
run: npm test
|
run: npm test
|
||||||
|
|
||||||
|
- name: Run server tests
|
||||||
|
working-directory: ./server
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
|
|
@ -65,6 +78,15 @@ jobs:
|
||||||
- name: Ensure npm CLI supports OIDC trusted publishing
|
- name: Ensure npm CLI supports OIDC trusted publishing
|
||||||
run: npm install -g npm@^11.5.1
|
run: npm install -g npm@^11.5.1
|
||||||
|
|
||||||
|
# `publish-all` pins `--tag v1-latest` on all four packages.
|
||||||
|
#
|
||||||
|
# Do NOT drop the flag: without an explicit tag npm assigns `latest`, so a
|
||||||
|
# v1 security release would drag `latest` back from v2 and every
|
||||||
|
# `npx @modelcontextprotocol/inspector` would silently get deprecated v1.
|
||||||
|
#
|
||||||
|
# Do NOT "simplify" the tag to `v1`: npm rejects any dist-tag that parses
|
||||||
|
# as a SemVer range, and `v1` does (`npm error Tag name must not be a
|
||||||
|
# valid SemVer range: v1`). That would fail the publish outright.
|
||||||
- run: npm run publish-all
|
- run: npm run publish-all
|
||||||
env:
|
env:
|
||||||
NPM_CONFIG_PROVENANCE: "true"
|
NPM_CONFIG_PROVENANCE: "true"
|
||||||
|
|
|
||||||
44
AGENTS.md
44
AGENTS.md
|
|
@ -1,35 +1,13 @@
|
||||||
# MCP Inspector Development Guide
|
# MCP Inspector Development Guide
|
||||||
|
|
||||||
> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
|
> # ⛔ v1 is deprecated — do not open pull requests against this branch
|
||||||
|
>
|
||||||
## Build Commands
|
> **This branch (`v1/main`) accepts security fixes only, and only by way of
|
||||||
|
> issue creation.**
|
||||||
- Build all: `npm run build`
|
>
|
||||||
- Build client: `npm run build-client`
|
> **Agents must NEVER attempt to create a pull request against `v1/main`.** File
|
||||||
- Build server: `npm run build-server`
|
> an issue describing the security problem instead. A human maintainer decides
|
||||||
- Development mode: `npm run dev` (use `npm run dev:windows` on Windows)
|
> whether it warrants a v1 release and implements it.
|
||||||
- Format code: `npm run prettier-fix`
|
>
|
||||||
- Client lint: `cd client && npm run lint`
|
> Everything else — features, bug fixes, refactors, docs — belongs in **v2**.
|
||||||
|
> Do not port it here, and do not "helpfully" fix it here in passing.
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
- Use TypeScript with proper type annotations
|
|
||||||
- Follow React functional component patterns with hooks
|
|
||||||
- Use ES modules (import/export) not CommonJS
|
|
||||||
- Use Prettier for formatting (auto-formatted on commit)
|
|
||||||
- Follow existing naming conventions:
|
|
||||||
- camelCase for variables and functions
|
|
||||||
- PascalCase for component names and types
|
|
||||||
- kebab-case for file names
|
|
||||||
- Use async/await for asynchronous operations
|
|
||||||
- Implement proper error handling with try/catch blocks
|
|
||||||
- Use Tailwind CSS for styling in the client
|
|
||||||
- Keep components small and focused on a single responsibility
|
|
||||||
|
|
||||||
## Project Organization
|
|
||||||
|
|
||||||
The project is organized as a monorepo with workspaces:
|
|
||||||
|
|
||||||
- `client/`: React frontend with Vite, TypeScript and Tailwind
|
|
||||||
- `server/`: Express backend with TypeScript
|
|
||||||
- `cli/`: Command-line interface for testing and invoking MCP server methods directly
|
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -2,7 +2,17 @@
|
||||||
|
|
||||||
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
The MCP inspector is a developer tool for testing and debugging MCP servers.
|
||||||
|
|
||||||

|
> # ⚠️ Deprecated
|
||||||
|
>
|
||||||
|
> **`@modelcontextprotocol/inspector` v1 is deprecated.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> npx @modelcontextprotocol/inspector@latest
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> v1 receives **security fixes only**. All new development happens in v2.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,20 @@ import { fileURLToPath } from "url";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 deprecation notice. Written to stderr, never stdout: `--cli` mode emits
|
||||||
|
* JSON on stdout and callers pipe it, so a banner there would corrupt output.
|
||||||
|
*/
|
||||||
|
const DEPRECATION_NOTICE = [
|
||||||
|
"⚠️ @modelcontextprotocol/inspector v1 is deprecated.",
|
||||||
|
" Upgrade: npx @modelcontextprotocol/inspector@latest",
|
||||||
|
" v1 receives security fixes only.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function printDeprecationNotice(): void {
|
||||||
|
console.error(`\n${DEPRECATION_NOTICE}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
|
|
@ -378,6 +392,8 @@ async function main(): Promise<void> {
|
||||||
handleError(error);
|
handleError(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
printDeprecationNotice();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const args = parseArgs();
|
const args = parseArgs();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,13 @@
|
||||||
|
> # ⚠️ Deprecated
|
||||||
|
>
|
||||||
|
> **`@modelcontextprotocol/inspector` v1 is deprecated.**
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> npx @modelcontextprotocol/inspector@latest
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> v1 receives **security fixes only**. All new development happens in v2.
|
||||||
|
|
||||||
# React + TypeScript + Vite
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,11 @@ async function startDevClient(clientOptions) {
|
||||||
|
|
||||||
// Give vite time to start before opening or logging the URL
|
// Give vite time to start before opening or logging the URL
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`\n⚠️ @modelcontextprotocol/inspector v1 is deprecated.\n` +
|
||||||
|
` Upgrade: npx @modelcontextprotocol/inspector@latest\n` +
|
||||||
|
` v1 receives security fixes only.`,
|
||||||
|
);
|
||||||
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
|
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
|
||||||
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
||||||
console.log("🌐 Opening browser...");
|
console.log("🌐 Opening browser...");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import { z } from "zod";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import AuthDebugger from "./components/AuthDebugger";
|
import AuthDebugger from "./components/AuthDebugger";
|
||||||
import ConsoleTab from "./components/ConsoleTab";
|
import ConsoleTab from "./components/ConsoleTab";
|
||||||
|
import DeprecationBanner from "./components/DeprecationBanner";
|
||||||
import HistoryAndNotifications from "./components/HistoryAndNotifications";
|
import HistoryAndNotifications from "./components/HistoryAndNotifications";
|
||||||
import PingTab from "./components/PingTab";
|
import PingTab from "./components/PingTab";
|
||||||
import PromptsTab, { Prompt } from "./components/PromptsTab";
|
import PromptsTab, { Prompt } from "./components/PromptsTab";
|
||||||
|
|
@ -1367,429 +1368,434 @@ const App = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex flex-col h-screen bg-background">
|
||||||
<div
|
<DeprecationBanner />
|
||||||
style={{
|
<div className="flex flex-1 min-h-0">
|
||||||
width: sidebarWidth,
|
|
||||||
minWidth: 200,
|
|
||||||
maxWidth: 600,
|
|
||||||
transition: isSidebarDragging ? "none" : "width 0.15s",
|
|
||||||
}}
|
|
||||||
className="bg-card border-r border-border flex flex-col h-full relative"
|
|
||||||
>
|
|
||||||
<Sidebar
|
|
||||||
connectionStatus={connectionStatus}
|
|
||||||
transportType={transportType}
|
|
||||||
setTransportType={setTransportType}
|
|
||||||
command={command}
|
|
||||||
setCommand={setCommand}
|
|
||||||
args={args}
|
|
||||||
setArgs={setArgs}
|
|
||||||
sseUrl={sseUrl}
|
|
||||||
setSseUrl={setSseUrl}
|
|
||||||
env={env}
|
|
||||||
setEnv={setEnv}
|
|
||||||
config={config}
|
|
||||||
setConfig={setConfig}
|
|
||||||
customHeaders={customHeaders}
|
|
||||||
setCustomHeaders={setCustomHeaders}
|
|
||||||
oauthClientId={oauthClientId}
|
|
||||||
setOauthClientId={setOauthClientId}
|
|
||||||
oauthClientSecret={oauthClientSecret}
|
|
||||||
setOauthClientSecret={setOauthClientSecret}
|
|
||||||
oauthScope={oauthScope}
|
|
||||||
setOauthScope={setOauthScope}
|
|
||||||
onConnect={connectMcpServer}
|
|
||||||
onDisconnect={disconnectMcpServer}
|
|
||||||
logLevel={logLevel}
|
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
|
||||||
loggingSupported={!!serverCapabilities?.logging || false}
|
|
||||||
connectionType={connectionType}
|
|
||||||
setConnectionType={setConnectionType}
|
|
||||||
serverImplementation={serverImplementation}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleSidebarDragStart}
|
|
||||||
style={{
|
style={{
|
||||||
cursor: "col-resize",
|
width: sidebarWidth,
|
||||||
position: "absolute",
|
minWidth: 200,
|
||||||
top: 0,
|
maxWidth: 600,
|
||||||
right: 0,
|
transition: isSidebarDragging ? "none" : "width 0.15s",
|
||||||
width: 6,
|
|
||||||
height: "100%",
|
|
||||||
zIndex: 10,
|
|
||||||
background: isSidebarDragging ? "rgba(0,0,0,0.08)" : "transparent",
|
|
||||||
}}
|
|
||||||
aria-label="Resize sidebar"
|
|
||||||
data-testid="sidebar-drag-handle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{mcpClient ? (
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
className="w-full p-4"
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setActiveTab(value);
|
|
||||||
window.location.hash = value;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TabsList className="mb-4 py-0">
|
|
||||||
<TabsTrigger
|
|
||||||
value="resources"
|
|
||||||
disabled={!serverCapabilities?.resources}
|
|
||||||
>
|
|
||||||
<Files className="w-4 h-4 mr-2" />
|
|
||||||
Resources
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="prompts"
|
|
||||||
disabled={!serverCapabilities?.prompts}
|
|
||||||
>
|
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
|
||||||
Prompts
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="tools"
|
|
||||||
disabled={!serverCapabilities?.tools}
|
|
||||||
>
|
|
||||||
<Hammer className="w-4 h-4 mr-2" />
|
|
||||||
Tools
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="tasks"
|
|
||||||
disabled={!serverCapabilities?.tasks}
|
|
||||||
>
|
|
||||||
<ListTodo className="w-4 h-4 mr-2" />
|
|
||||||
Tasks
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="apps">
|
|
||||||
<AppWindow className="w-4 h-4 mr-2" />
|
|
||||||
Apps
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="ping">
|
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
|
||||||
Ping
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="sampling" className="relative">
|
|
||||||
<Hash className="w-4 h-4 mr-2" />
|
|
||||||
Sampling
|
|
||||||
{pendingSampleRequests.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">
|
|
||||||
{pendingSampleRequests.length}
|
|
||||||
</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
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="auth">
|
|
||||||
<Key className="w-4 h-4 mr-2" />
|
|
||||||
Auth
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="metadata">
|
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Metadata
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<div className="w-full">
|
|
||||||
{!serverCapabilities?.resources &&
|
|
||||||
!serverCapabilities?.prompts &&
|
|
||||||
!serverCapabilities?.tools ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-center p-4">
|
|
||||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
|
||||||
The connected server does not support any MCP
|
|
||||||
capabilities
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<PingTab
|
|
||||||
onPingClick={() => {
|
|
||||||
void sendMCPRequest(
|
|
||||||
{
|
|
||||||
method: "ping" as const,
|
|
||||||
},
|
|
||||||
EmptyResultSchema,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ResourcesTab
|
|
||||||
resources={resources}
|
|
||||||
resourceTemplates={resourceTemplates}
|
|
||||||
listResources={() => {
|
|
||||||
clearError("resources");
|
|
||||||
listResources();
|
|
||||||
}}
|
|
||||||
clearResources={() => {
|
|
||||||
setResources([]);
|
|
||||||
setNextResourceCursor(undefined);
|
|
||||||
}}
|
|
||||||
listResourceTemplates={() => {
|
|
||||||
clearError("resources");
|
|
||||||
listResourceTemplates();
|
|
||||||
}}
|
|
||||||
clearResourceTemplates={() => {
|
|
||||||
setResourceTemplates([]);
|
|
||||||
setNextResourceTemplateCursor(undefined);
|
|
||||||
}}
|
|
||||||
readResource={(uri, options) => {
|
|
||||||
clearError("resources");
|
|
||||||
readResource(uri, options);
|
|
||||||
}}
|
|
||||||
selectedResource={selectedResource}
|
|
||||||
setSelectedResource={(resource) => {
|
|
||||||
clearError("resources");
|
|
||||||
setSelectedResource(resource);
|
|
||||||
}}
|
|
||||||
resourceSubscriptionsSupported={
|
|
||||||
serverCapabilities?.resources?.subscribe || false
|
|
||||||
}
|
|
||||||
resourceSubscriptions={resourceSubscriptions}
|
|
||||||
subscribeToResource={(uri) => {
|
|
||||||
clearError("resources");
|
|
||||||
subscribeToResource(uri);
|
|
||||||
}}
|
|
||||||
unsubscribeFromResource={(uri) => {
|
|
||||||
clearError("resources");
|
|
||||||
unsubscribeFromResource(uri);
|
|
||||||
}}
|
|
||||||
handleCompletion={handleCompletion}
|
|
||||||
completionsSupported={completionsSupported}
|
|
||||||
resourceContent={resourceContent}
|
|
||||||
nextCursor={nextResourceCursor}
|
|
||||||
nextTemplateCursor={nextResourceTemplateCursor}
|
|
||||||
error={errors.resources}
|
|
||||||
/>
|
|
||||||
<PromptsTab
|
|
||||||
prompts={prompts}
|
|
||||||
listPrompts={() => {
|
|
||||||
clearError("prompts");
|
|
||||||
listPrompts();
|
|
||||||
}}
|
|
||||||
clearPrompts={() => {
|
|
||||||
setPrompts([]);
|
|
||||||
setNextPromptCursor(undefined);
|
|
||||||
}}
|
|
||||||
getPrompt={(name, args) => {
|
|
||||||
clearError("prompts");
|
|
||||||
getPrompt(name, args);
|
|
||||||
}}
|
|
||||||
selectedPrompt={selectedPrompt}
|
|
||||||
setSelectedPrompt={(prompt) => {
|
|
||||||
clearError("prompts");
|
|
||||||
setSelectedPrompt(prompt);
|
|
||||||
setPromptContent("");
|
|
||||||
}}
|
|
||||||
handleCompletion={handleCompletion}
|
|
||||||
completionsSupported={completionsSupported}
|
|
||||||
promptContent={promptContent}
|
|
||||||
nextCursor={nextPromptCursor}
|
|
||||||
error={errors.prompts}
|
|
||||||
/>
|
|
||||||
<ToolsTab
|
|
||||||
serverSupportsTaskRequests={
|
|
||||||
!!serverCapabilities?.tasks?.requests?.tools?.call
|
|
||||||
}
|
|
||||||
tools={tools}
|
|
||||||
listTools={() => {
|
|
||||||
clearError("tools");
|
|
||||||
listTools();
|
|
||||||
}}
|
|
||||||
clearTools={() => {
|
|
||||||
setTools([]);
|
|
||||||
setNextToolCursor(undefined);
|
|
||||||
cacheToolOutputSchemas([]);
|
|
||||||
}}
|
|
||||||
callTool={async (
|
|
||||||
name: string,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
metadata?: Record<string, unknown>,
|
|
||||||
runAsTask?: boolean,
|
|
||||||
) => {
|
|
||||||
clearError("tools");
|
|
||||||
setToolResult(null);
|
|
||||||
const result = await callTool(
|
|
||||||
name,
|
|
||||||
params,
|
|
||||||
metadata,
|
|
||||||
runAsTask,
|
|
||||||
);
|
|
||||||
const calledTool = tools.find(
|
|
||||||
(tool) => tool.name === name,
|
|
||||||
);
|
|
||||||
if (calledTool && hasAppResourceUri(calledTool)) {
|
|
||||||
setPrefilledAppsToolCall({
|
|
||||||
id: ++prefilledAppsToolCallIdRef.current,
|
|
||||||
toolName: name,
|
|
||||||
params: cloneToolParams(params),
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setPrefilledAppsToolCall(null);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}}
|
|
||||||
selectedTool={selectedTool}
|
|
||||||
setSelectedTool={(tool) => {
|
|
||||||
clearError("tools");
|
|
||||||
setSelectedTool(tool);
|
|
||||||
setToolResult(null);
|
|
||||||
}}
|
|
||||||
toolResult={toolResult}
|
|
||||||
isPollingTask={isPollingTask}
|
|
||||||
nextCursor={nextToolCursor}
|
|
||||||
error={errors.tools}
|
|
||||||
resourceContent={resourceContentMap}
|
|
||||||
resourceError={resourceErrorMap}
|
|
||||||
onReadResource={(uri: string) => {
|
|
||||||
clearError("resources");
|
|
||||||
readResource(uri);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TasksTab
|
|
||||||
tasks={tasks}
|
|
||||||
listTasks={() => {
|
|
||||||
clearError("tasks");
|
|
||||||
listTasks();
|
|
||||||
}}
|
|
||||||
clearTasks={() => {
|
|
||||||
setTasks([]);
|
|
||||||
setNextTaskCursor(undefined);
|
|
||||||
}}
|
|
||||||
cancelTask={cancelTask}
|
|
||||||
selectedTask={selectedTask}
|
|
||||||
setSelectedTask={(task) => {
|
|
||||||
clearError("tasks");
|
|
||||||
setSelectedTask(task);
|
|
||||||
}}
|
|
||||||
error={errors.tasks}
|
|
||||||
nextCursor={nextTaskCursor}
|
|
||||||
/>
|
|
||||||
<AppsTab
|
|
||||||
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
|
|
||||||
tools={tools}
|
|
||||||
listTools={() => {
|
|
||||||
clearError("tools");
|
|
||||||
listTools();
|
|
||||||
}}
|
|
||||||
callTool={async (
|
|
||||||
name: string,
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
metadata?: Record<string, unknown>,
|
|
||||||
runAsTask?: boolean,
|
|
||||||
) => {
|
|
||||||
clearError("tools");
|
|
||||||
setToolResult(null);
|
|
||||||
return callTool(name, params, metadata, runAsTask);
|
|
||||||
}}
|
|
||||||
prefilledToolCall={prefilledAppsToolCall}
|
|
||||||
onPrefilledToolCallConsumed={(callId) => {
|
|
||||||
setPrefilledAppsToolCall((prev) =>
|
|
||||||
prev?.id === callId ? null : prev,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
error={errors.tools}
|
|
||||||
mcpClient={mcpClient}
|
|
||||||
onNotification={(notification) => {
|
|
||||||
setNotifications((prev) => [...prev, notification]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ConsoleTab />
|
|
||||||
<PingTab
|
|
||||||
onPingClick={() => {
|
|
||||||
void sendMCPRequest(
|
|
||||||
{
|
|
||||||
method: "ping" as const,
|
|
||||||
},
|
|
||||||
EmptyResultSchema,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<SamplingTab
|
|
||||||
pendingRequests={pendingSampleRequests}
|
|
||||||
onApprove={handleApproveSampling}
|
|
||||||
onReject={handleRejectSampling}
|
|
||||||
/>
|
|
||||||
<ElicitationTab
|
|
||||||
pendingRequests={pendingElicitationRequests}
|
|
||||||
onResolve={handleResolveElicitation}
|
|
||||||
/>
|
|
||||||
<RootsTab
|
|
||||||
roots={roots}
|
|
||||||
setRoots={setRoots}
|
|
||||||
onRootsChange={handleRootsChange}
|
|
||||||
/>
|
|
||||||
<AuthDebuggerWrapper />
|
|
||||||
<MetadataTab
|
|
||||||
metadata={metadata}
|
|
||||||
onMetadataChange={handleMetadataChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
) : isAuthDebuggerVisible ? (
|
|
||||||
<Tabs
|
|
||||||
defaultValue={"auth"}
|
|
||||||
className="w-full p-4"
|
|
||||||
onValueChange={(value) => (window.location.hash = value)}
|
|
||||||
>
|
|
||||||
<AuthDebuggerWrapper />
|
|
||||||
</Tabs>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
|
||||||
<p className="text-lg text-gray-500 dark:text-gray-400">
|
|
||||||
Connect to an MCP server to start inspecting
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Need to configure authentication?
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsAuthDebuggerVisible(true)}
|
|
||||||
>
|
|
||||||
Open Auth Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="relative border-t border-border"
|
|
||||||
style={{
|
|
||||||
height: `${historyPaneHeight}px`,
|
|
||||||
}}
|
}}
|
||||||
|
className="bg-card border-r border-border flex flex-col h-full relative"
|
||||||
>
|
>
|
||||||
|
<Sidebar
|
||||||
|
connectionStatus={connectionStatus}
|
||||||
|
transportType={transportType}
|
||||||
|
setTransportType={setTransportType}
|
||||||
|
command={command}
|
||||||
|
setCommand={setCommand}
|
||||||
|
args={args}
|
||||||
|
setArgs={setArgs}
|
||||||
|
sseUrl={sseUrl}
|
||||||
|
setSseUrl={setSseUrl}
|
||||||
|
env={env}
|
||||||
|
setEnv={setEnv}
|
||||||
|
config={config}
|
||||||
|
setConfig={setConfig}
|
||||||
|
customHeaders={customHeaders}
|
||||||
|
setCustomHeaders={setCustomHeaders}
|
||||||
|
oauthClientId={oauthClientId}
|
||||||
|
setOauthClientId={setOauthClientId}
|
||||||
|
oauthClientSecret={oauthClientSecret}
|
||||||
|
setOauthClientSecret={setOauthClientSecret}
|
||||||
|
oauthScope={oauthScope}
|
||||||
|
setOauthScope={setOauthScope}
|
||||||
|
onConnect={connectMcpServer}
|
||||||
|
onDisconnect={disconnectMcpServer}
|
||||||
|
logLevel={logLevel}
|
||||||
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
|
connectionType={connectionType}
|
||||||
|
setConnectionType={setConnectionType}
|
||||||
|
serverImplementation={serverImplementation}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
|
onMouseDown={handleSidebarDragStart}
|
||||||
onMouseDown={handleDragStart}
|
style={{
|
||||||
>
|
cursor: "col-resize",
|
||||||
<div className="w-8 h-1 rounded-full bg-border" />
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 6,
|
||||||
|
height: "100%",
|
||||||
|
zIndex: 10,
|
||||||
|
background: isSidebarDragging
|
||||||
|
? "rgba(0,0,0,0.08)"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
aria-label="Resize sidebar"
|
||||||
|
data-testid="sidebar-drag-handle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{mcpClient ? (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
className="w-full p-4"
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
window.location.hash = value;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabsList className="mb-4 py-0">
|
||||||
|
<TabsTrigger
|
||||||
|
value="resources"
|
||||||
|
disabled={!serverCapabilities?.resources}
|
||||||
|
>
|
||||||
|
<Files className="w-4 h-4 mr-2" />
|
||||||
|
Resources
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="prompts"
|
||||||
|
disabled={!serverCapabilities?.prompts}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
|
Prompts
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="tools"
|
||||||
|
disabled={!serverCapabilities?.tools}
|
||||||
|
>
|
||||||
|
<Hammer className="w-4 h-4 mr-2" />
|
||||||
|
Tools
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="tasks"
|
||||||
|
disabled={!serverCapabilities?.tasks}
|
||||||
|
>
|
||||||
|
<ListTodo className="w-4 h-4 mr-2" />
|
||||||
|
Tasks
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="apps">
|
||||||
|
<AppWindow className="w-4 h-4 mr-2" />
|
||||||
|
Apps
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ping">
|
||||||
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
|
Ping
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sampling" className="relative">
|
||||||
|
<Hash className="w-4 h-4 mr-2" />
|
||||||
|
Sampling
|
||||||
|
{pendingSampleRequests.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">
|
||||||
|
{pendingSampleRequests.length}
|
||||||
|
</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
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="auth">
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
Auth
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="metadata">
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Metadata
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
{!serverCapabilities?.resources &&
|
||||||
|
!serverCapabilities?.prompts &&
|
||||||
|
!serverCapabilities?.tools ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-center p-4">
|
||||||
|
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||||
|
The connected server does not support any MCP
|
||||||
|
capabilities
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PingTab
|
||||||
|
onPingClick={() => {
|
||||||
|
void sendMCPRequest(
|
||||||
|
{
|
||||||
|
method: "ping" as const,
|
||||||
|
},
|
||||||
|
EmptyResultSchema,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ResourcesTab
|
||||||
|
resources={resources}
|
||||||
|
resourceTemplates={resourceTemplates}
|
||||||
|
listResources={() => {
|
||||||
|
clearError("resources");
|
||||||
|
listResources();
|
||||||
|
}}
|
||||||
|
clearResources={() => {
|
||||||
|
setResources([]);
|
||||||
|
setNextResourceCursor(undefined);
|
||||||
|
}}
|
||||||
|
listResourceTemplates={() => {
|
||||||
|
clearError("resources");
|
||||||
|
listResourceTemplates();
|
||||||
|
}}
|
||||||
|
clearResourceTemplates={() => {
|
||||||
|
setResourceTemplates([]);
|
||||||
|
setNextResourceTemplateCursor(undefined);
|
||||||
|
}}
|
||||||
|
readResource={(uri, options) => {
|
||||||
|
clearError("resources");
|
||||||
|
readResource(uri, options);
|
||||||
|
}}
|
||||||
|
selectedResource={selectedResource}
|
||||||
|
setSelectedResource={(resource) => {
|
||||||
|
clearError("resources");
|
||||||
|
setSelectedResource(resource);
|
||||||
|
}}
|
||||||
|
resourceSubscriptionsSupported={
|
||||||
|
serverCapabilities?.resources?.subscribe || false
|
||||||
|
}
|
||||||
|
resourceSubscriptions={resourceSubscriptions}
|
||||||
|
subscribeToResource={(uri) => {
|
||||||
|
clearError("resources");
|
||||||
|
subscribeToResource(uri);
|
||||||
|
}}
|
||||||
|
unsubscribeFromResource={(uri) => {
|
||||||
|
clearError("resources");
|
||||||
|
unsubscribeFromResource(uri);
|
||||||
|
}}
|
||||||
|
handleCompletion={handleCompletion}
|
||||||
|
completionsSupported={completionsSupported}
|
||||||
|
resourceContent={resourceContent}
|
||||||
|
nextCursor={nextResourceCursor}
|
||||||
|
nextTemplateCursor={nextResourceTemplateCursor}
|
||||||
|
error={errors.resources}
|
||||||
|
/>
|
||||||
|
<PromptsTab
|
||||||
|
prompts={prompts}
|
||||||
|
listPrompts={() => {
|
||||||
|
clearError("prompts");
|
||||||
|
listPrompts();
|
||||||
|
}}
|
||||||
|
clearPrompts={() => {
|
||||||
|
setPrompts([]);
|
||||||
|
setNextPromptCursor(undefined);
|
||||||
|
}}
|
||||||
|
getPrompt={(name, args) => {
|
||||||
|
clearError("prompts");
|
||||||
|
getPrompt(name, args);
|
||||||
|
}}
|
||||||
|
selectedPrompt={selectedPrompt}
|
||||||
|
setSelectedPrompt={(prompt) => {
|
||||||
|
clearError("prompts");
|
||||||
|
setSelectedPrompt(prompt);
|
||||||
|
setPromptContent("");
|
||||||
|
}}
|
||||||
|
handleCompletion={handleCompletion}
|
||||||
|
completionsSupported={completionsSupported}
|
||||||
|
promptContent={promptContent}
|
||||||
|
nextCursor={nextPromptCursor}
|
||||||
|
error={errors.prompts}
|
||||||
|
/>
|
||||||
|
<ToolsTab
|
||||||
|
serverSupportsTaskRequests={
|
||||||
|
!!serverCapabilities?.tasks?.requests?.tools?.call
|
||||||
|
}
|
||||||
|
tools={tools}
|
||||||
|
listTools={() => {
|
||||||
|
clearError("tools");
|
||||||
|
listTools();
|
||||||
|
}}
|
||||||
|
clearTools={() => {
|
||||||
|
setTools([]);
|
||||||
|
setNextToolCursor(undefined);
|
||||||
|
cacheToolOutputSchemas([]);
|
||||||
|
}}
|
||||||
|
callTool={async (
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
runAsTask?: boolean,
|
||||||
|
) => {
|
||||||
|
clearError("tools");
|
||||||
|
setToolResult(null);
|
||||||
|
const result = await callTool(
|
||||||
|
name,
|
||||||
|
params,
|
||||||
|
metadata,
|
||||||
|
runAsTask,
|
||||||
|
);
|
||||||
|
const calledTool = tools.find(
|
||||||
|
(tool) => tool.name === name,
|
||||||
|
);
|
||||||
|
if (calledTool && hasAppResourceUri(calledTool)) {
|
||||||
|
setPrefilledAppsToolCall({
|
||||||
|
id: ++prefilledAppsToolCallIdRef.current,
|
||||||
|
toolName: name,
|
||||||
|
params: cloneToolParams(params),
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setPrefilledAppsToolCall(null);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}}
|
||||||
|
selectedTool={selectedTool}
|
||||||
|
setSelectedTool={(tool) => {
|
||||||
|
clearError("tools");
|
||||||
|
setSelectedTool(tool);
|
||||||
|
setToolResult(null);
|
||||||
|
}}
|
||||||
|
toolResult={toolResult}
|
||||||
|
isPollingTask={isPollingTask}
|
||||||
|
nextCursor={nextToolCursor}
|
||||||
|
error={errors.tools}
|
||||||
|
resourceContent={resourceContentMap}
|
||||||
|
resourceError={resourceErrorMap}
|
||||||
|
onReadResource={(uri: string) => {
|
||||||
|
clearError("resources");
|
||||||
|
readResource(uri);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TasksTab
|
||||||
|
tasks={tasks}
|
||||||
|
listTasks={() => {
|
||||||
|
clearError("tasks");
|
||||||
|
listTasks();
|
||||||
|
}}
|
||||||
|
clearTasks={() => {
|
||||||
|
setTasks([]);
|
||||||
|
setNextTaskCursor(undefined);
|
||||||
|
}}
|
||||||
|
cancelTask={cancelTask}
|
||||||
|
selectedTask={selectedTask}
|
||||||
|
setSelectedTask={(task) => {
|
||||||
|
clearError("tasks");
|
||||||
|
setSelectedTask(task);
|
||||||
|
}}
|
||||||
|
error={errors.tasks}
|
||||||
|
nextCursor={nextTaskCursor}
|
||||||
|
/>
|
||||||
|
<AppsTab
|
||||||
|
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
|
||||||
|
tools={tools}
|
||||||
|
listTools={() => {
|
||||||
|
clearError("tools");
|
||||||
|
listTools();
|
||||||
|
}}
|
||||||
|
callTool={async (
|
||||||
|
name: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
runAsTask?: boolean,
|
||||||
|
) => {
|
||||||
|
clearError("tools");
|
||||||
|
setToolResult(null);
|
||||||
|
return callTool(name, params, metadata, runAsTask);
|
||||||
|
}}
|
||||||
|
prefilledToolCall={prefilledAppsToolCall}
|
||||||
|
onPrefilledToolCallConsumed={(callId) => {
|
||||||
|
setPrefilledAppsToolCall((prev) =>
|
||||||
|
prev?.id === callId ? null : prev,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
error={errors.tools}
|
||||||
|
mcpClient={mcpClient}
|
||||||
|
onNotification={(notification) => {
|
||||||
|
setNotifications((prev) => [...prev, notification]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ConsoleTab />
|
||||||
|
<PingTab
|
||||||
|
onPingClick={() => {
|
||||||
|
void sendMCPRequest(
|
||||||
|
{
|
||||||
|
method: "ping" as const,
|
||||||
|
},
|
||||||
|
EmptyResultSchema,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SamplingTab
|
||||||
|
pendingRequests={pendingSampleRequests}
|
||||||
|
onApprove={handleApproveSampling}
|
||||||
|
onReject={handleRejectSampling}
|
||||||
|
/>
|
||||||
|
<ElicitationTab
|
||||||
|
pendingRequests={pendingElicitationRequests}
|
||||||
|
onResolve={handleResolveElicitation}
|
||||||
|
/>
|
||||||
|
<RootsTab
|
||||||
|
roots={roots}
|
||||||
|
setRoots={setRoots}
|
||||||
|
onRootsChange={handleRootsChange}
|
||||||
|
/>
|
||||||
|
<AuthDebuggerWrapper />
|
||||||
|
<MetadataTab
|
||||||
|
metadata={metadata}
|
||||||
|
onMetadataChange={handleMetadataChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
) : isAuthDebuggerVisible ? (
|
||||||
|
<Tabs
|
||||||
|
defaultValue={"auth"}
|
||||||
|
className="w-full p-4"
|
||||||
|
onValueChange={(value) => (window.location.hash = value)}
|
||||||
|
>
|
||||||
|
<AuthDebuggerWrapper />
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
|
<p className="text-lg text-gray-500 dark:text-gray-400">
|
||||||
|
Connect to an MCP server to start inspecting
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Need to configure authentication?
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAuthDebuggerVisible(true)}
|
||||||
|
>
|
||||||
|
Open Auth Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full overflow-auto">
|
<div
|
||||||
<HistoryAndNotifications
|
className="relative border-t border-border"
|
||||||
requestHistory={requestHistory}
|
style={{
|
||||||
serverNotifications={notifications}
|
height: `${historyPaneHeight}px`,
|
||||||
onClearHistory={clearRequestHistory}
|
}}
|
||||||
onClearNotifications={handleClearNotifications}
|
>
|
||||||
/>
|
<div
|
||||||
|
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
|
||||||
|
onMouseDown={handleDragStart}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-border" />
|
||||||
|
</div>
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<HistoryAndNotifications
|
||||||
|
requestHistory={requestHistory}
|
||||||
|
serverNotifications={notifications}
|
||||||
|
onClearHistory={clearRequestHistory}
|
||||||
|
onClearNotifications={handleClearNotifications}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
62
client/src/components/DeprecationBanner.tsx
Normal file
62
client/src/components/DeprecationBanner.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
const DISMISSED_KEY = "mcp-inspector-v1-deprecation-dismissed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 deprecation notice shown at the top of the app.
|
||||||
|
*
|
||||||
|
* Dismissal is persisted in localStorage so it stays hidden across reloads.
|
||||||
|
* A storage failure (Safari private mode, disabled storage) must not break the
|
||||||
|
* app, so both reads and writes are guarded — the banner simply reappears.
|
||||||
|
*/
|
||||||
|
const DeprecationBanner = () => {
|
||||||
|
const [dismissed, setDismissed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DISMISSED_KEY) === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dismissed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, "true");
|
||||||
|
} catch {
|
||||||
|
// Ignore: dismissal just won't persist across reloads.
|
||||||
|
}
|
||||||
|
setDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex items-center justify-between gap-4 px-4 py-2 bg-yellow-100 text-yellow-900 border-b border-yellow-300 dark:bg-yellow-900/40 dark:text-yellow-100 dark:border-yellow-700"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="font-semibold">
|
||||||
|
⚠️ MCP Inspector v1 is deprecated.
|
||||||
|
</span>{" "}
|
||||||
|
Upgrade with{" "}
|
||||||
|
<code className="px-1 py-0.5 rounded bg-yellow-200 dark:bg-yellow-800">
|
||||||
|
npx @modelcontextprotocol/inspector@latest
|
||||||
|
</code>
|
||||||
|
. v1 receives security fixes only.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
aria-label="Dismiss deprecation notice"
|
||||||
|
className="shrink-0 p-1 rounded hover:bg-yellow-200 dark:hover:bg-yellow-800"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeprecationBanner;
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 142 KiB |
92
package-lock.json
generated
92
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"client",
|
"client",
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
"cli"
|
"cli"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-cli": "^1.0.0",
|
"@modelcontextprotocol/inspector-cli": "^1.0.2",
|
||||||
"@modelcontextprotocol/inspector-client": "^1.0.0",
|
"@modelcontextprotocol/inspector-client": "^1.0.2",
|
||||||
"@modelcontextprotocol/inspector-server": "^1.0.0",
|
"@modelcontextprotocol/inspector-server": "^1.0.2",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.0",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
|
|
@ -74,7 +74,7 @@
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mcp-ui/client": "^6.0.0",
|
"@mcp-ui/client": "^6.0.0",
|
||||||
|
|
@ -1612,9 +1612,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.17",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
|
||||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
"integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
|
|
@ -1731,9 +1731,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
|
||||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -4538,16 +4538,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
|
|
@ -5223,9 +5223,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
|
|
@ -6785,9 +6785,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||||
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -7281,9 +7281,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.30",
|
"version": "4.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz",
|
||||||
"integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==",
|
"integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
|
|
@ -7494,9 +7494,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
|
|
@ -8949,9 +8949,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.2.0",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -9774,9 +9774,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -10371,9 +10371,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.19",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -10391,7 +10391,7 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.17",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -11108,16 +11108,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rimraf/node_modules/brace-expansion": {
|
"node_modules/rimraf/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rimraf/node_modules/glob": {
|
"node_modules/rimraf/node_modules/glob": {
|
||||||
|
|
@ -13254,7 +13254,7 @@
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
|
|
@ -13273,10 +13273,12 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
"@types/shell-quote": "^1.7.5",
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
package.json
28
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector",
|
"name": "@modelcontextprotocol/inspector",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"description": "Model Context Protocol inspector",
|
"description": "Model Context Protocol inspector",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||||
|
|
@ -42,19 +42,20 @@
|
||||||
"start-client": "cd client && npm run preview",
|
"start-client": "cd client && npm run preview",
|
||||||
"test": "npm run prettier-check && cd client && npm test",
|
"test": "npm run prettier-check && cd client && npm test",
|
||||||
"test-cli": "cd cli && npm run test",
|
"test-cli": "cd cli && npm run test",
|
||||||
|
"test-server": "cd server && npm run test",
|
||||||
"test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client",
|
"test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client",
|
||||||
"prettier-fix": "prettier --write .",
|
"prettier-fix": "prettier --write .",
|
||||||
"prettier-check": "prettier --check .",
|
"prettier-check": "prettier --check .",
|
||||||
"lint": "prettier --check . && cd client && npm run lint",
|
"lint": "prettier --check . && cd client && npm run lint",
|
||||||
"prepare": "husky && npm run build",
|
"prepare": "husky && npm run build",
|
||||||
"publish-all": "npm publish --workspaces --access public && npm publish --access public",
|
"publish-all": "npm publish --workspaces --access public --tag v1-latest && npm publish --access public --tag v1-latest",
|
||||||
"update-version": "node scripts/update-version.js",
|
"update-version": "node scripts/update-version.js",
|
||||||
"check-version": "node scripts/check-version-consistency.js"
|
"check-version": "node scripts/check-version-consistency.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/inspector-cli": "^1.0.0",
|
"@modelcontextprotocol/inspector-cli": "^1.0.2",
|
||||||
"@modelcontextprotocol/inspector-client": "^1.0.0",
|
"@modelcontextprotocol/inspector-client": "^1.0.2",
|
||||||
"@modelcontextprotocol/inspector-server": "^1.0.0",
|
"@modelcontextprotocol/inspector-server": "^1.0.2",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.0",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
|
|
@ -80,13 +81,20 @@
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"get-intrinsic": "1.3.0",
|
"get-intrinsic": "1.3.0",
|
||||||
"shell-quote": "^1.8.4",
|
"shell-quote": "^1.8.4",
|
||||||
"hono": "^4.12.25",
|
"hono": "^4.13.3",
|
||||||
"form-data": "^4.0.6",
|
"form-data": "^4.0.6",
|
||||||
"@babel/core@<=7.29.0": "^7.29.6",
|
"@babel/core@<=7.29.0": "^7.29.6",
|
||||||
"esbuild@<0.28.1": "^0.28.1",
|
"esbuild@<0.28.1": "^0.28.1",
|
||||||
"minimatch@<3.1.4": "3.1.5",
|
"minimatch@<3.1.4": "3.1.5",
|
||||||
"js-yaml@<4": "3.15.0",
|
"js-yaml@<4": "3.15.1",
|
||||||
"js-yaml@>=4": "4.2.0"
|
"js-yaml@>=4": "4.3.1",
|
||||||
|
"@hono/node-server@<1.19.15": "^1.19.15",
|
||||||
|
"ip-address@<=10.3.0": "^10.5.0",
|
||||||
|
"fast-uri@<3.1.5": "^3.1.5",
|
||||||
|
"brace-expansion@<2": "^1.1.18",
|
||||||
|
"brace-expansion@>=4": "^5.0.9",
|
||||||
|
"nanoid@<3.3.18": "^3.3.18",
|
||||||
|
"postcss@<=8.5.22": "^8.5.26"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.7.5"
|
"node": ">=22.7.5"
|
||||||
|
|
@ -95,5 +103,9 @@
|
||||||
"**/*.{js,ts,jsx,tsx,json,md}": [
|
"**/*.{js,ts,jsx,tsx,json,md}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild": false,
|
||||||
|
"fsevents": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-server",
|
"name": "@modelcontextprotocol/inspector-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"description": "Server-side application for the Model Context Protocol inspector",
|
"description": "Server-side application for the Model Context Protocol inspector",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
"author": "Model Context Protocol a Series of LF Projects, LLC.",
|
||||||
|
|
@ -24,15 +24,19 @@
|
||||||
"build": "tsc && shx cp -R static build",
|
"build": "tsc && shx cp -R static build",
|
||||||
"start": "node build/index.js",
|
"start": "node build/index.js",
|
||||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||||
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL"
|
"dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL",
|
||||||
|
"test": "tsc -p tsconfig.test.json && vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
"@types/shell-quote": "^1.7.5",
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||||
|
|
|
||||||
359
server/src/__tests__/proxy-security.test.ts
Normal file
359
server/src/__tests__/proxy-security.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for proxy-security.ts
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - isBlockedProxyAddress: IPv4, IPv6, IPv4-mapped IPv6 (hex + dotted), edge cases
|
||||||
|
* - assertSafeProxyTarget: safe IPs, blocked IPs, literal-IP hosts, DNS errors
|
||||||
|
* - createPinnedAgent: correct agent type, lookup always returns pinned IP
|
||||||
|
* - TOCTOU guarantee: the pinned agent never invokes the OS resolver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import type dnsTypes from "node:dns";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import nodeFetch from "node-fetch";
|
||||||
|
import { vi, describe, it, expect, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock node:dns/promises before importing the module under test so that
|
||||||
|
// assertSafeProxyTarget's dnsLookup is replaceable in each test.
|
||||||
|
vi.mock("node:dns/promises", () => ({
|
||||||
|
lookup: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as dns from "node:dns/promises";
|
||||||
|
import {
|
||||||
|
isBlockedProxyAddress,
|
||||||
|
assertSafeProxyTarget,
|
||||||
|
createPinnedAgent,
|
||||||
|
ProxyTargetError,
|
||||||
|
} from "../proxy-security.js";
|
||||||
|
|
||||||
|
// Convenience cast — vitest doesn't know the mock shape yet.
|
||||||
|
const mockLookup = dns.lookup as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isBlockedProxyAddress
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("isBlockedProxyAddress", () => {
|
||||||
|
describe("IPv4 link-local (169.254.0.0/16)", () => {
|
||||||
|
it("blocks 169.254.169.254 (AWS metadata)", () => {
|
||||||
|
expect(isBlockedProxyAddress("169.254.169.254")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks 169.254.0.1 (first address in range)", () => {
|
||||||
|
expect(isBlockedProxyAddress("169.254.0.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks 169.254.255.255 (last address in range)", () => {
|
||||||
|
expect(isBlockedProxyAddress("169.254.255.255")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows 169.253.0.1 (just outside the range)", () => {
|
||||||
|
expect(isBlockedProxyAddress("169.253.0.1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows 170.254.0.1 (just outside the range)", () => {
|
||||||
|
expect(isBlockedProxyAddress("170.254.0.1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows loopback 127.0.0.1", () => {
|
||||||
|
expect(isBlockedProxyAddress("127.0.0.1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a public IP", () => {
|
||||||
|
expect(isBlockedProxyAddress("93.184.216.34")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IPv6 link-local (fe80::/10)", () => {
|
||||||
|
it("blocks fe80::1", () => {
|
||||||
|
expect(isBlockedProxyAddress("fe80::1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks fe80::aabb:ccdd (arbitrary link-local)", () => {
|
||||||
|
expect(isBlockedProxyAddress("fe80::aabb:ccdd")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows ::1 (loopback)", () => {
|
||||||
|
expect(isBlockedProxyAddress("::1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows 2001:db8::1 (documentation range)", () => {
|
||||||
|
expect(isBlockedProxyAddress("2001:db8::1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AWS IPv6 IMDS (fd00:ec2::254)", () => {
|
||||||
|
it("blocks fd00:ec2::254 exactly", () => {
|
||||||
|
expect(isBlockedProxyAddress("fd00:ec2::254")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows fd00:ec2::255 (adjacent address)", () => {
|
||||||
|
expect(isBlockedProxyAddress("fd00:ec2::255")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IPv4-mapped IPv6 variants of 169.254.169.254", () => {
|
||||||
|
it("blocks dotted form ::ffff:169.254.169.254", () => {
|
||||||
|
expect(isBlockedProxyAddress("::ffff:169.254.169.254")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks hex form ::ffff:a9fe:a9fe (WHATWG URL serialization)", () => {
|
||||||
|
expect(isBlockedProxyAddress("::ffff:a9fe:a9fe")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows IPv4-mapped loopback ::ffff:127.0.0.1", () => {
|
||||||
|
expect(isBlockedProxyAddress("::ffff:127.0.0.1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("non-IP strings", () => {
|
||||||
|
it("allows empty string (not an IP)", () => {
|
||||||
|
expect(isBlockedProxyAddress("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows hostname string (not an IP)", () => {
|
||||||
|
expect(isBlockedProxyAddress("example.com")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// assertSafeProxyTarget
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("assertSafeProxyTarget", () => {
|
||||||
|
it("resolves and allows a safe hostname", async () => {
|
||||||
|
mockLookup.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]);
|
||||||
|
|
||||||
|
const addrs = await assertSafeProxyTarget(new URL("http://example.com/"));
|
||||||
|
expect(addrs).toEqual(["93.184.216.34"]);
|
||||||
|
expect(mockLookup).toHaveBeenCalledWith("example.com", { all: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ProxyTargetError when host resolves to blocked IP", async () => {
|
||||||
|
mockLookup.mockResolvedValueOnce([
|
||||||
|
{ address: "169.254.169.254", family: 4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertSafeProxyTarget(new URL("http://evil.example.com/")),
|
||||||
|
).rejects.toThrow(ProxyTargetError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ProxyTargetError when any resolved IP is blocked (mixed results)", async () => {
|
||||||
|
mockLookup.mockResolvedValueOnce([
|
||||||
|
{ address: "93.184.216.34", family: 4 },
|
||||||
|
{ address: "169.254.169.254", family: 4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertSafeProxyTarget(new URL("http://dual.example.com/")),
|
||||||
|
).rejects.toThrow(ProxyTargetError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ProxyTargetError when DNS lookup fails", async () => {
|
||||||
|
mockLookup.mockRejectedValueOnce(new Error("ENOTFOUND"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertSafeProxyTarget(new URL("http://nonexistent.invalid/")),
|
||||||
|
).rejects.toThrow(ProxyTargetError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips DNS lookup for literal IPv4 hosts", async () => {
|
||||||
|
const addrs = await assertSafeProxyTarget(new URL("http://127.0.0.1/path"));
|
||||||
|
expect(addrs).toEqual(["127.0.0.1"]);
|
||||||
|
expect(mockLookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ProxyTargetError for literal blocked IPv4", async () => {
|
||||||
|
await expect(
|
||||||
|
assertSafeProxyTarget(new URL("http://169.254.169.254/")),
|
||||||
|
).rejects.toThrow(ProxyTargetError);
|
||||||
|
expect(mockLookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips DNS lookup for literal IPv6 hosts", async () => {
|
||||||
|
const addrs = await assertSafeProxyTarget(new URL("http://[::1]/"));
|
||||||
|
expect(addrs).toEqual(["::1"]);
|
||||||
|
expect(mockLookup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all validated addresses so caller can pick one for pinning", async () => {
|
||||||
|
mockLookup.mockResolvedValueOnce([
|
||||||
|
{ address: "192.0.2.1", family: 4 },
|
||||||
|
{ address: "192.0.2.2", family: 4 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const addrs = await assertSafeProxyTarget(new URL("http://multi.example/"));
|
||||||
|
expect(addrs).toHaveLength(2);
|
||||||
|
expect(addrs).toContain("192.0.2.1");
|
||||||
|
expect(addrs).toContain("192.0.2.2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createPinnedAgent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The lookup hook `createPinnedAgent` installed on the agent. */
|
||||||
|
type PinnedLookup = (
|
||||||
|
hostname: string,
|
||||||
|
options: dnsTypes.LookupOptions,
|
||||||
|
callback: (
|
||||||
|
err: NodeJS.ErrnoException | null,
|
||||||
|
address: string | dnsTypes.LookupAddress[],
|
||||||
|
family?: number,
|
||||||
|
) => void,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
function pinnedLookup(agent: http.Agent | https.Agent): PinnedLookup {
|
||||||
|
const { lookup } = (agent as http.Agent & { options: http.AgentOptions })
|
||||||
|
.options;
|
||||||
|
if (typeof lookup !== "function") {
|
||||||
|
throw new Error("expected createPinnedAgent to install a lookup hook");
|
||||||
|
}
|
||||||
|
return lookup as PinnedLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createPinnedAgent", () => {
|
||||||
|
it("returns an http.Agent for http: protocol", () => {
|
||||||
|
const agent = createPinnedAgent("http:", ["127.0.0.1"]);
|
||||||
|
expect(agent).toBeInstanceOf(http.Agent);
|
||||||
|
expect(agent).not.toBeInstanceOf(https.Agent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an https.Agent for https: protocol", () => {
|
||||||
|
const agent = createPinnedAgent("https:", ["127.0.0.1"]);
|
||||||
|
expect(agent).toBeInstanceOf(https.Agent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pinned lookup returns the IPv4 address regardless of queried hostname", () => {
|
||||||
|
const lookup = pinnedLookup(createPinnedAgent("http:", ["192.0.2.99"]));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
lookup("example.com", {}, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, "192.0.2.99", 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pinned lookup returns the IPv6 address and family 6", () => {
|
||||||
|
const lookup = pinnedLookup(createPinnedAgent("http:", ["2001:db8::1"]));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
lookup("example.com", {}, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, "2001:db8::1", 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Node's net.connect runs with autoSelectFamily on (Node >= 20), so it calls
|
||||||
|
// the hook with { all: true } and requires the ARRAY callback shape. Handing
|
||||||
|
// it a scalar there fails every hostname request with ERR_INVALID_IP_ADDRESS.
|
||||||
|
it("pinned lookup returns the array shape when called with { all: true }", () => {
|
||||||
|
const lookup = pinnedLookup(createPinnedAgent("http:", ["192.0.2.99"]));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
lookup("example.com", { all: true }, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, [
|
||||||
|
{ address: "192.0.2.99", family: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pinned lookup returns the array shape for IPv6 too", () => {
|
||||||
|
const lookup = pinnedLookup(createPinnedAgent("http:", ["2001:db8::1"]));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
lookup("example.com", { all: true }, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, [
|
||||||
|
{ address: "2001:db8::1", family: 6 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every validated address is handed to the hook, so Node keeps its normal
|
||||||
|
// address-family fallback for a dual-stack host whose first address is
|
||||||
|
// unreachable — while still only ever seeing prevalidated addresses.
|
||||||
|
it("passes every validated address through for dual-stack fallback", () => {
|
||||||
|
const lookup = pinnedLookup(
|
||||||
|
createPinnedAgent("http:", ["2001:db8::1", "192.0.2.7"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
lookup("dual.example", { all: true }, callback);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, [
|
||||||
|
{ address: "2001:db8::1", family: 6 },
|
||||||
|
{ address: "192.0.2.7", family: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when given no validated address", () => {
|
||||||
|
expect(() => createPinnedAgent("http:", [])).toThrow(ProxyTargetError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A dual-stack host whose first (IPv6) address is dead: the connection must
|
||||||
|
// still complete via the second, validated address.
|
||||||
|
it("falls back to a later validated address when the first is unreachable", async () => {
|
||||||
|
const server = http.createServer((_req, res) => res.end("fallback-ok"));
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve()),
|
||||||
|
);
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2001:db8::1 is the documentation range — nothing answers there.
|
||||||
|
const agent = createPinnedAgent("http:", ["2001:db8::1", "127.0.0.1"]);
|
||||||
|
const response = await nodeFetch(`http://dual.invalid:${port}/`, {
|
||||||
|
agent,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.text()).resolves.toBe("fallback-ok");
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("TOCTOU guarantee: lookup never invokes the OS resolver", () => {
|
||||||
|
const lookup = pinnedLookup(createPinnedAgent("http:", ["10.0.0.1"]));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
lookup("any-hostname.example", {}, callback);
|
||||||
|
|
||||||
|
// The callback is invoked synchronously with the fixed IP — no resolver.
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).toHaveBeenCalledWith(null, "10.0.0.1", 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// End-to-end over the production call path: node-fetch → http.Agent →
|
||||||
|
// net.connect. The hostname is deliberately unresolvable, so the request can
|
||||||
|
// only succeed if the connection went to the pinned IP, and it exercises the
|
||||||
|
// real { all: true } callback shape rather than a hand-rolled invocation.
|
||||||
|
it("routes a real request to the pinned IP for an unresolvable hostname", async () => {
|
||||||
|
const server = http.createServer((_req, res) => res.end("pinned-ok"));
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve()),
|
||||||
|
);
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = createPinnedAgent("http:", ["127.0.0.1"]);
|
||||||
|
const response = await nodeFetch(
|
||||||
|
`http://pinned-target.invalid:${port}/`,
|
||||||
|
{ agent },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.text()).resolves.toBe("pinned-ok");
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,11 @@ import cors from "cors";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import { parse as shellParseArgs } from "shell-quote";
|
import { parse as shellParseArgs } from "shell-quote";
|
||||||
import nodeFetch, { Headers as NodeHeaders } from "node-fetch";
|
import nodeFetch, { Headers as NodeHeaders } from "node-fetch";
|
||||||
|
import {
|
||||||
|
assertSafeProxyTarget,
|
||||||
|
createPinnedAgent,
|
||||||
|
ProxyTargetError,
|
||||||
|
} from "./proxy-security.js";
|
||||||
|
|
||||||
// Type-compatible wrappers for node-fetch to work with browser-style types
|
// Type-compatible wrappers for node-fetch to work with browser-style types
|
||||||
const fetch = nodeFetch;
|
const fetch = nodeFetch;
|
||||||
|
|
@ -29,8 +34,6 @@ import rateLimit from "express-rate-limit";
|
||||||
import { findActualExecutable } from "spawn-rx";
|
import { findActualExecutable } from "spawn-rx";
|
||||||
import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js";
|
import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js";
|
||||||
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import { lookup as dnsLookup } from "node:dns/promises";
|
|
||||||
import { isIP } from "node:net";
|
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
|
|
@ -863,91 +866,17 @@ app.get("/health", (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Raised when the /fetch proxy is asked to reach a disallowed target, so the
|
|
||||||
// route can distinguish an SSRF rejection (403) from an upstream failure (500).
|
|
||||||
class ProxyTargetError extends Error {}
|
|
||||||
|
|
||||||
const MAX_PROXY_REDIRECTS = 5;
|
const MAX_PROXY_REDIRECTS = 5;
|
||||||
|
|
||||||
// The /fetch proxy is used by the client to reach OAuth discovery/token
|
|
||||||
// endpoints on the connected MCP server (bypassing browser CORS). Connecting
|
|
||||||
// to loopback and RFC1918/private LAN hosts is a core use case (testing local
|
|
||||||
// or self-hosted MCP servers), so those stay reachable. We only block
|
|
||||||
// link-local addresses — 169.254.0.0/16 (the AWS/GCP/Azure/etc. cloud metadata
|
|
||||||
// endpoint 169.254.169.254 lives here) and IPv6 link-local / the AWS IPv6 IMDS
|
|
||||||
// address — which are never legitimate proxy targets and are the real SSRF
|
|
||||||
// exfiltration vector.
|
|
||||||
function isBlockedProxyAddress(ip: string): boolean {
|
|
||||||
const kind = isIP(ip);
|
|
||||||
if (kind === 4) {
|
|
||||||
const parts = ip.split(".").map(Number);
|
|
||||||
// 169.254.0.0/16 — IPv4 link-local (incl. cloud metadata 169.254.169.254)
|
|
||||||
return parts[0] === 169 && parts[1] === 254;
|
|
||||||
}
|
|
||||||
if (kind === 6) {
|
|
||||||
const addr = ip.toLowerCase();
|
|
||||||
// IPv4-mapped IPv6 (::ffff:x). The WHATWG URL parser serializes the embedded
|
|
||||||
// IPv4 in hex (::ffff:a9fe:a9fe for 169.254.169.254), while DNS and other
|
|
||||||
// sources may use the dotted form (::ffff:169.254.169.254) — handle both by
|
|
||||||
// extracting the IPv4 and re-checking it.
|
|
||||||
const mapped = addr.match(/^::ffff:(.+)$/);
|
|
||||||
if (mapped) {
|
|
||||||
const tail = mapped[1];
|
|
||||||
if (isIP(tail) === 4) {
|
|
||||||
return isBlockedProxyAddress(tail);
|
|
||||||
}
|
|
||||||
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
||||||
if (hex) {
|
|
||||||
const high = parseInt(hex[1], 16);
|
|
||||||
const low = parseInt(hex[2], 16);
|
|
||||||
return isBlockedProxyAddress(
|
|
||||||
`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// fe80::/10 — IPv6 link-local
|
|
||||||
if (/^fe[89ab]/.test(addr)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// fd00:ec2::254 — AWS IPv6 instance metadata endpoint
|
|
||||||
return addr === "fd00:ec2::254";
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolves the target host and rejects it if any resolved address is a blocked
|
|
||||||
// (link-local/metadata) address, mitigating SSRF including the DNS case where a
|
|
||||||
// hostname resolves to the cloud metadata IP.
|
|
||||||
async function assertSafeProxyTarget(parsedUrl: URL): Promise<void> {
|
|
||||||
const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
||||||
|
|
||||||
let addresses: string[];
|
|
||||||
if (isIP(host)) {
|
|
||||||
addresses = [host];
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
|
|
||||||
} catch {
|
|
||||||
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addresses.length === 0) {
|
|
||||||
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const address of addresses) {
|
|
||||||
if (isBlockedProxyAddress(address)) {
|
|
||||||
throw new ProxyTargetError(
|
|
||||||
`Refusing to proxy request to blocked address (${address})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch that follows redirects manually so every hop's target is re-validated
|
// Fetch that follows redirects manually so every hop's target is re-validated
|
||||||
// against assertSafeProxyTarget — otherwise an allowed host could redirect the
|
// against assertSafeProxyTarget — otherwise an allowed host could redirect the
|
||||||
// proxy into a blocked internal/metadata address.
|
// proxy into a blocked internal/metadata address.
|
||||||
|
//
|
||||||
|
// On each hop we call assertSafeProxyTarget (which resolves DNS and validates
|
||||||
|
// every returned IP) and then pass a createPinnedAgent to node-fetch so it
|
||||||
|
// connects to the IP we just validated instead of re-resolving the hostname.
|
||||||
|
// This eliminates the TOCTOU window between the block-list check and the
|
||||||
|
// actual TCP connection that a DNS-rebinding attack would exploit.
|
||||||
async function safeProxyFetch(initialUrl: string, init?: RequestInit) {
|
async function safeProxyFetch(initialUrl: string, init?: RequestInit) {
|
||||||
let currentUrl = new URL(initialUrl);
|
let currentUrl = new URL(initialUrl);
|
||||||
let method = init?.method ?? "GET";
|
let method = init?.method ?? "GET";
|
||||||
|
|
@ -958,13 +887,20 @@ async function safeProxyFetch(initialUrl: string, init?: RequestInit) {
|
||||||
if (!["http:", "https:"].includes(currentUrl.protocol)) {
|
if (!["http:", "https:"].includes(currentUrl.protocol)) {
|
||||||
throw new ProxyTargetError("Only http/https URLs are allowed");
|
throw new ProxyTargetError("Only http/https URLs are allowed");
|
||||||
}
|
}
|
||||||
await assertSafeProxyTarget(currentUrl);
|
|
||||||
|
// Resolve DNS once, validate every address, and pin the connection to that
|
||||||
|
// validated set so node-fetch never does a second DNS lookup. All of them
|
||||||
|
// are passed through, so Node's address-family fallback still works while
|
||||||
|
// the reachable set stays limited to what was just validated.
|
||||||
|
const validatedAddresses = await assertSafeProxyTarget(currentUrl);
|
||||||
|
const agent = createPinnedAgent(currentUrl.protocol, validatedAddresses);
|
||||||
|
|
||||||
const response = await fetch(currentUrl.toString(), {
|
const response = await fetch(currentUrl.toString(), {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
|
agent,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status >= 300 && response.status < 400) {
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
|
@ -1088,6 +1024,11 @@ const HOST = process.env.HOST || "localhost";
|
||||||
|
|
||||||
const server = app.listen(PORT, HOST);
|
const server = app.listen(PORT, HOST);
|
||||||
server.on("listening", () => {
|
server.on("listening", () => {
|
||||||
|
console.log(
|
||||||
|
`\n⚠️ @modelcontextprotocol/inspector v1 is deprecated.\n` +
|
||||||
|
` Upgrade: npx @modelcontextprotocol/inspector@latest\n` +
|
||||||
|
` v1 receives security fixes only.\n`,
|
||||||
|
);
|
||||||
console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`);
|
console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`);
|
||||||
if (!authDisabled) {
|
if (!authDisabled) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
183
server/src/proxy-security.ts
Normal file
183
server/src/proxy-security.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* SSRF-prevention helpers for the /fetch reverse proxy.
|
||||||
|
*
|
||||||
|
* Extracted from index.ts so they can be unit-tested independently.
|
||||||
|
*
|
||||||
|
* ## DNS-rebinding / TOCTOU threat model
|
||||||
|
*
|
||||||
|
* `assertSafeProxyTarget` resolves the target hostname and validates every
|
||||||
|
* returned IP against the block-list. The resolved IPs are returned to the
|
||||||
|
* caller so they can be passed to `createPinnedAgent`, which builds an
|
||||||
|
* http/https.Agent whose `lookup` callback unconditionally returns the
|
||||||
|
* pre-validated IP. The agent is then passed to `node-fetch`, which calls the
|
||||||
|
* lookup hook instead of doing its own DNS query.
|
||||||
|
*
|
||||||
|
* Without this pinning there is a TOCTOU race: an attacker who controls the
|
||||||
|
* target domain can flip its DNS record to 169.254.169.254 (AWS/GCP/Azure
|
||||||
|
* instance-metadata) in the window between `assertSafeProxyTarget` returning
|
||||||
|
* and `fetch` performing its own resolution. Pinning eliminates that window.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import type dns from "node:dns";
|
||||||
|
import { lookup as dnsLookup } from "node:dns/promises";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Block-list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if `ip` is a link-local or cloud-metadata address that must
|
||||||
|
* never be reachable through the proxy.
|
||||||
|
*
|
||||||
|
* Blocked ranges:
|
||||||
|
* - 169.254.0.0/16 (IPv4 link-local; includes 169.254.169.254 IMDS)
|
||||||
|
* - ::ffff:169.254.x.x (IPv4-mapped IPv6 variants of the above)
|
||||||
|
* - fe80::/10 (IPv6 link-local)
|
||||||
|
* - fd00:ec2::254 (AWS IPv6 instance-metadata)
|
||||||
|
*/
|
||||||
|
export function isBlockedProxyAddress(ip: string): boolean {
|
||||||
|
const kind = isIP(ip);
|
||||||
|
if (kind === 4) {
|
||||||
|
const parts = ip.split(".").map(Number);
|
||||||
|
return parts[0] === 169 && parts[1] === 254;
|
||||||
|
}
|
||||||
|
if (kind === 6) {
|
||||||
|
const addr = ip.toLowerCase();
|
||||||
|
|
||||||
|
// IPv4-mapped IPv6 — two serialization forms:
|
||||||
|
// dotted ::ffff:169.254.169.254
|
||||||
|
// hex ::ffff:a9fe:a9fe
|
||||||
|
const mapped = addr.match(/^::ffff:(.+)$/);
|
||||||
|
if (mapped) {
|
||||||
|
const tail = mapped[1];
|
||||||
|
if (isIP(tail) === 4) {
|
||||||
|
return isBlockedProxyAddress(tail);
|
||||||
|
}
|
||||||
|
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
||||||
|
if (hex) {
|
||||||
|
const high = parseInt(hex[1], 16);
|
||||||
|
const low = parseInt(hex[2], 16);
|
||||||
|
return isBlockedProxyAddress(
|
||||||
|
`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^fe[89ab]/.test(addr)) return true; // fe80::/10
|
||||||
|
return addr === "fd00:ec2::254"; // AWS IPv6 IMDS
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DNS validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Raised when the /fetch proxy is asked to reach a disallowed target. */
|
||||||
|
export class ProxyTargetError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves `parsedUrl`'s hostname and asserts that every returned IP is
|
||||||
|
* outside the block-list.
|
||||||
|
*
|
||||||
|
* Returns the list of validated IP strings so the caller can pin one of them
|
||||||
|
* into an HTTP agent (see `createPinnedAgent`), eliminating the TOCTOU window
|
||||||
|
* between this check and the actual `fetch()` call.
|
||||||
|
*
|
||||||
|
* @throws {ProxyTargetError} if the host cannot be resolved or any resolved
|
||||||
|
* address falls inside the block-list.
|
||||||
|
*/
|
||||||
|
export async function assertSafeProxyTarget(parsedUrl: URL): Promise<string[]> {
|
||||||
|
const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
||||||
|
|
||||||
|
let addresses: string[];
|
||||||
|
if (isIP(host)) {
|
||||||
|
addresses = [host];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
|
||||||
|
} catch {
|
||||||
|
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
throw new ProxyTargetError(`Could not resolve host: ${host}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const address of addresses) {
|
||||||
|
if (isBlockedProxyAddress(address)) {
|
||||||
|
throw new ProxyTargetError(
|
||||||
|
`Refusing to proxy request to blocked address (${address})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IP-pinned agent factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an `http.Agent` or `https.Agent` whose internal `lookup` hook always
|
||||||
|
* returns `pinnedIps`, bypassing the OS resolver entirely.
|
||||||
|
*
|
||||||
|
* Pass this agent to `node-fetch` on every hop of `safeProxyFetch` so the TCP
|
||||||
|
* connection always goes to an IP that was validated by `assertSafeProxyTarget`
|
||||||
|
* — even if the domain's DNS record changes mid-flight.
|
||||||
|
*
|
||||||
|
* Every address `assertSafeProxyTarget` returned is handed to the hook, not
|
||||||
|
* just the first, so Node keeps its normal address-family fallback: a
|
||||||
|
* dual-stack host whose first address is unreachable (IPv6 on an IPv4-only
|
||||||
|
* network, say) still connects via a later one. The set it may choose from is
|
||||||
|
* exactly the prevalidated set, so the TOCTOU guarantee is unchanged.
|
||||||
|
*
|
||||||
|
* @param protocol - `"http:"` or `"https:"` (from `URL.protocol`)
|
||||||
|
* @param pinnedIps - Validated IPv4/IPv6 address strings, in preference order
|
||||||
|
*/
|
||||||
|
export function createPinnedAgent(
|
||||||
|
protocol: string,
|
||||||
|
pinnedIps: string[],
|
||||||
|
): http.Agent | https.Agent {
|
||||||
|
if (pinnedIps.length === 0) {
|
||||||
|
throw new ProxyTargetError("No validated address to connect to");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved: dns.LookupAddress[] = pinnedIps.map((address) => ({
|
||||||
|
address,
|
||||||
|
family: isIP(address) === 6 ? 6 : 4,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Node.js lookup signature: (hostname, options, callback).
|
||||||
|
//
|
||||||
|
// The callback shape depends on `options.all`. Node's `net.connect` runs with
|
||||||
|
// `autoSelectFamily` enabled by default (Node >= 20), so it calls the hook
|
||||||
|
// with `{ all: true }` and expects an ARRAY of `{ address, family }` — handing
|
||||||
|
// it the scalar form there fails the connection with ERR_INVALID_IP_ADDRESS.
|
||||||
|
// Both shapes are supported so the agent works on either call path.
|
||||||
|
const lookup = (
|
||||||
|
_hostname: string,
|
||||||
|
opts: dns.LookupOptions | undefined,
|
||||||
|
callback: (
|
||||||
|
err: NodeJS.ErrnoException | null,
|
||||||
|
address: string | dns.LookupAddress[],
|
||||||
|
family?: number,
|
||||||
|
) => void,
|
||||||
|
): void => {
|
||||||
|
if (opts?.all) {
|
||||||
|
callback(null, resolved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback(null, resolved[0].address, resolved[0].family);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (protocol === "https:") {
|
||||||
|
return new https.Agent({ lookup } as https.AgentOptions);
|
||||||
|
}
|
||||||
|
return new http.Agent({ lookup } as http.AgentOptions);
|
||||||
|
}
|
||||||
|
|
@ -12,5 +12,5 @@
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "packages", "**/*.spec.ts"]
|
"exclude": ["node_modules", "packages", "**/*.spec.ts", "src/**/__tests__/**"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
server/tsconfig.test.json
Normal file
12
server/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// The build config excludes __tests__ so the suite never lands in the
|
||||||
|
// published `build/` output. This config exists only so the tests are still
|
||||||
|
// type-checked; it emits nothing and is run by `npm test`.
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "packages"]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue