1723 lines
55 KiB
TypeScript
1723 lines
55 KiB
TypeScript
import {
|
|
ClientRequest,
|
|
CompatibilityCallToolResult,
|
|
CompatibilityCallToolResultSchema,
|
|
CreateMessageResult,
|
|
EmptyResultSchema,
|
|
GetPromptResultSchema,
|
|
ListPromptsResultSchema,
|
|
ListResourcesResultSchema,
|
|
ListResourceTemplatesResultSchema,
|
|
ListToolsResultSchema,
|
|
ReadResourceResultSchema,
|
|
Resource,
|
|
ResourceTemplate,
|
|
Root,
|
|
ServerNotification,
|
|
Tool,
|
|
LoggingLevel,
|
|
Task,
|
|
GetTaskResultSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
import type {
|
|
AnySchema,
|
|
SchemaOutput,
|
|
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
|
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
|
|
import {
|
|
hasValidMetaName,
|
|
hasValidMetaPrefix,
|
|
isReservedMetaKey,
|
|
} from "@/utils/metaUtils";
|
|
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
|
|
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
|
|
import { OAuthStateMachine } from "./lib/oauth-state-machine";
|
|
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
|
|
import { cleanParams } from "./utils/paramUtils";
|
|
import type { JsonSchemaType } from "./utils/jsonUtils";
|
|
import React, {
|
|
Suspense,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useConnection } from "./lib/hooks/useConnection";
|
|
import {
|
|
useDraggablePane,
|
|
useDraggableSidebar,
|
|
} from "./lib/hooks/useDraggablePane";
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
AppWindow,
|
|
Bell,
|
|
Files,
|
|
FolderTree,
|
|
Hammer,
|
|
Hash,
|
|
Key,
|
|
ListTodo,
|
|
MessageSquare,
|
|
Settings,
|
|
} from "lucide-react";
|
|
|
|
import { z } from "zod";
|
|
import "./App.css";
|
|
import AuthDebugger from "./components/AuthDebugger";
|
|
import ConsoleTab from "./components/ConsoleTab";
|
|
import HistoryAndNotifications from "./components/HistoryAndNotifications";
|
|
import PingTab from "./components/PingTab";
|
|
import PromptsTab, { Prompt } from "./components/PromptsTab";
|
|
import ResourcesTab from "./components/ResourcesTab";
|
|
import RootsTab from "./components/RootsTab";
|
|
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
|
|
import Sidebar from "./components/Sidebar";
|
|
import ToolsTab from "./components/ToolsTab";
|
|
import TasksTab from "./components/TasksTab";
|
|
import AppsTab from "./components/AppsTab";
|
|
import { InspectorConfig } from "./lib/configurationTypes";
|
|
import {
|
|
getMCPProxyAddress,
|
|
getMCPProxyAuthToken,
|
|
getInitialSseUrl,
|
|
getInitialTransportType,
|
|
getInitialCommand,
|
|
getInitialArgs,
|
|
initializeInspectorConfig,
|
|
saveInspectorConfig,
|
|
getMCPTaskTtl,
|
|
} from "./utils/configUtils";
|
|
import ElicitationTab, {
|
|
PendingElicitationRequest,
|
|
ElicitationResponse,
|
|
} from "./components/ElicitationTab";
|
|
import {
|
|
CustomHeaders,
|
|
migrateFromLegacyAuth,
|
|
} from "./lib/types/customHeaders";
|
|
import MetadataTab from "./components/MetadataTab";
|
|
|
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
|
|
|
type PrefilledAppsToolCall = {
|
|
id: number;
|
|
toolName: string;
|
|
params: Record<string, unknown>;
|
|
result: CompatibilityCallToolResult;
|
|
};
|
|
|
|
const hasAppResourceUri = (tool: Tool): boolean => {
|
|
return Boolean(getToolUiResourceUri(tool));
|
|
};
|
|
|
|
const cloneToolParams = (
|
|
source: Record<string, unknown>,
|
|
): Record<string, unknown> => {
|
|
try {
|
|
return structuredClone(source);
|
|
} catch {
|
|
return { ...source };
|
|
}
|
|
};
|
|
|
|
const filterReservedMetadata = (
|
|
metadata: Record<string, string>,
|
|
): Record<string, string> => {
|
|
return Object.entries(metadata).reduce<Record<string, string>>(
|
|
(acc, [key, value]) => {
|
|
if (
|
|
!isReservedMetaKey(key) &&
|
|
hasValidMetaPrefix(key) &&
|
|
hasValidMetaName(key)
|
|
) {
|
|
acc[key] = value;
|
|
}
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
};
|
|
|
|
const App = () => {
|
|
const [resources, setResources] = useState<Resource[]>([]);
|
|
const [resourceTemplates, setResourceTemplates] = useState<
|
|
ResourceTemplate[]
|
|
>([]);
|
|
const [resourceContent, setResourceContent] = useState<string>("");
|
|
const [resourceContentMap, setResourceContentMap] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
const [fetchingResources, setFetchingResources] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
|
const [promptContent, setPromptContent] = useState<string>("");
|
|
const [tools, setTools] = useState<Tool[]>([]);
|
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
const [toolResult, setToolResult] =
|
|
useState<CompatibilityCallToolResult | null>(null);
|
|
const [prefilledAppsToolCall, setPrefilledAppsToolCall] =
|
|
useState<PrefilledAppsToolCall | null>(null);
|
|
const [errors, setErrors] = useState<Record<string, string | null>>({
|
|
resources: null,
|
|
prompts: null,
|
|
tools: null,
|
|
tasks: null,
|
|
});
|
|
const [command, setCommand] = useState<string>(getInitialCommand);
|
|
const [args, setArgs] = useState<string>(getInitialArgs);
|
|
|
|
const [sseUrl, setSseUrl] = useState<string>(getInitialSseUrl);
|
|
const [transportType, setTransportType] = useState<
|
|
"stdio" | "sse" | "streamable-http"
|
|
>(getInitialTransportType);
|
|
const [connectionType, setConnectionType] = useState<"direct" | "proxy">(
|
|
() => {
|
|
return (
|
|
(localStorage.getItem("lastConnectionType") as "direct" | "proxy") ||
|
|
"proxy"
|
|
);
|
|
},
|
|
);
|
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
|
const [roots, setRoots] = useState<Root[]>([]);
|
|
const [env, setEnv] = useState<Record<string, string>>({});
|
|
|
|
const [config, setConfig] = useState<InspectorConfig>(() =>
|
|
initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY),
|
|
);
|
|
const [bearerToken, setBearerToken] = useState<string>(() => {
|
|
return localStorage.getItem("lastBearerToken") || "";
|
|
});
|
|
|
|
const [headerName, setHeaderName] = useState<string>(() => {
|
|
return localStorage.getItem("lastHeaderName") || "";
|
|
});
|
|
|
|
const [oauthClientId, setOauthClientId] = useState<string>(() => {
|
|
return localStorage.getItem("lastOauthClientId") || "";
|
|
});
|
|
|
|
const [oauthScope, setOauthScope] = useState<string>(() => {
|
|
return localStorage.getItem("lastOauthScope") || "";
|
|
});
|
|
|
|
const [oauthClientSecret, setOauthClientSecret] = useState<string>(() => {
|
|
return localStorage.getItem("lastOauthClientSecret") || "";
|
|
});
|
|
|
|
// Custom headers state with migration from legacy auth
|
|
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
|
|
const savedHeaders = localStorage.getItem("lastCustomHeaders");
|
|
if (savedHeaders) {
|
|
try {
|
|
return JSON.parse(savedHeaders);
|
|
} catch (error) {
|
|
console.warn(
|
|
`Failed to parse custom headers: "${savedHeaders}", will try legacy migration`,
|
|
error,
|
|
);
|
|
// Fall back to migration if JSON parsing fails
|
|
}
|
|
}
|
|
|
|
// Migrate from legacy auth if available
|
|
const legacyToken = localStorage.getItem("lastBearerToken") || "";
|
|
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
|
|
|
|
if (legacyToken) {
|
|
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
|
|
}
|
|
|
|
// Default to empty array
|
|
return [
|
|
{
|
|
name: "Authorization",
|
|
value: "Bearer ",
|
|
enabled: false,
|
|
},
|
|
];
|
|
});
|
|
|
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
|
Array<
|
|
PendingRequest & {
|
|
resolve: (result: CreateMessageResult) => void;
|
|
reject: (error: Error) => void;
|
|
}
|
|
>
|
|
>([]);
|
|
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
|
|
Array<
|
|
PendingElicitationRequest & {
|
|
resolve: (response: ElicitationResponse) => void;
|
|
decline: (error: Error) => void;
|
|
}
|
|
>
|
|
>([]);
|
|
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
|
|
|
const [authState, setAuthState] =
|
|
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
|
|
|
// Metadata state - persisted in localStorage
|
|
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
|
|
const savedMetadata = localStorage.getItem("lastMetadata");
|
|
if (savedMetadata) {
|
|
try {
|
|
const parsed = JSON.parse(savedMetadata);
|
|
if (parsed && typeof parsed === "object") {
|
|
return filterReservedMetadata(parsed);
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to parse saved metadata:", error);
|
|
}
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
|
setAuthState((prev) => ({ ...prev, ...updates }));
|
|
};
|
|
|
|
const handleMetadataChange = (newMetadata: Record<string, string>) => {
|
|
const sanitizedMetadata = filterReservedMetadata(newMetadata);
|
|
setMetadata(sanitizedMetadata);
|
|
localStorage.setItem("lastMetadata", JSON.stringify(sanitizedMetadata));
|
|
};
|
|
const nextRequestId = useRef(0);
|
|
const rootsRef = useRef<Root[]>([]);
|
|
|
|
const [selectedResource, setSelectedResource] = useState<Resource | null>(
|
|
null,
|
|
);
|
|
const [resourceSubscriptions, setResourceSubscriptions] = useState<
|
|
Set<string>
|
|
>(new Set<string>());
|
|
|
|
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
|
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
|
const [isPollingTask, setIsPollingTask] = useState(false);
|
|
const [nextResourceCursor, setNextResourceCursor] = useState<
|
|
string | undefined
|
|
>();
|
|
const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState<
|
|
string | undefined
|
|
>();
|
|
const [nextPromptCursor, setNextPromptCursor] = useState<
|
|
string | undefined
|
|
>();
|
|
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
|
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
|
|
const progressTokenRef = useRef(0);
|
|
const prefilledAppsToolCallIdRef = useRef(0);
|
|
|
|
const [activeTab, setActiveTab] = useState<string>(() => {
|
|
const hash = window.location.hash.slice(1);
|
|
const initialTab = hash || "resources";
|
|
return initialTab;
|
|
});
|
|
|
|
const currentTabRef = useRef<string>(activeTab);
|
|
const lastToolCallOriginTabRef = useRef<string>(activeTab);
|
|
|
|
useEffect(() => {
|
|
currentTabRef.current = activeTab;
|
|
}, [activeTab]);
|
|
|
|
const navigateToOriginatingTab = (originatingTab?: string) => {
|
|
if (!originatingTab) return;
|
|
|
|
const validTabs = [
|
|
...(serverCapabilities?.resources ? ["resources"] : []),
|
|
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
|
...(serverCapabilities?.tools ? ["tools"] : []),
|
|
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
|
"apps",
|
|
"ping",
|
|
"sampling",
|
|
"elicitations",
|
|
"roots",
|
|
"auth",
|
|
"metadata",
|
|
];
|
|
|
|
if (!validTabs.includes(originatingTab)) return;
|
|
|
|
setActiveTab(originatingTab);
|
|
window.location.hash = originatingTab;
|
|
|
|
setTimeout(() => {
|
|
setActiveTab(originatingTab);
|
|
window.location.hash = originatingTab;
|
|
}, 100);
|
|
};
|
|
|
|
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
|
const {
|
|
width: sidebarWidth,
|
|
isDragging: isSidebarDragging,
|
|
handleDragStart: handleSidebarDragStart,
|
|
} = useDraggableSidebar(320);
|
|
|
|
const selectedTaskRef = useRef<Task | null>(null);
|
|
useEffect(() => {
|
|
selectedTaskRef.current = selectedTask;
|
|
}, [selectedTask]);
|
|
|
|
const {
|
|
connectionStatus,
|
|
serverCapabilities,
|
|
serverImplementation,
|
|
mcpClient,
|
|
requestHistory,
|
|
clearRequestHistory,
|
|
makeRequest,
|
|
cancelTask: cancelMcpTask,
|
|
listTasks: listMcpTasks,
|
|
sendNotification,
|
|
handleCompletion,
|
|
completionsSupported,
|
|
connect: connectMcpServer,
|
|
disconnect: disconnectMcpServer,
|
|
} = useConnection({
|
|
transportType,
|
|
command,
|
|
args,
|
|
sseUrl,
|
|
env,
|
|
customHeaders,
|
|
oauthClientId,
|
|
oauthClientSecret,
|
|
oauthScope,
|
|
config,
|
|
connectionType,
|
|
onNotification: (notification) => {
|
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
|
|
|
if (notification.method === "notifications/tasks/list_changed") {
|
|
void listTasks();
|
|
}
|
|
|
|
if (notification.method === "notifications/tasks/status") {
|
|
const task = notification.params as unknown as Task;
|
|
setTasks((prev) => {
|
|
const exists = prev.some((t) => t.taskId === task.taskId);
|
|
if (exists) {
|
|
return prev.map((t) => (t.taskId === task.taskId ? task : t));
|
|
} else {
|
|
return [task, ...prev];
|
|
}
|
|
});
|
|
if (selectedTaskRef.current?.taskId === task.taskId) {
|
|
setSelectedTask(task);
|
|
}
|
|
}
|
|
},
|
|
onPendingRequest: (request, resolve, reject) => {
|
|
const currentTab = lastToolCallOriginTabRef.current;
|
|
setPendingSampleRequests((prev) => [
|
|
...prev,
|
|
{
|
|
id: nextRequestId.current++,
|
|
request,
|
|
originatingTab: currentTab,
|
|
resolve,
|
|
reject,
|
|
},
|
|
]);
|
|
|
|
setActiveTab("sampling");
|
|
window.location.hash = "sampling";
|
|
},
|
|
onElicitationRequest: (request, resolve) => {
|
|
const currentTab = lastToolCallOriginTabRef.current;
|
|
|
|
setPendingElicitationRequests((prev) => [
|
|
...prev,
|
|
{
|
|
id: nextRequestId.current++,
|
|
request: {
|
|
id: nextRequestId.current,
|
|
message: request.params.message,
|
|
requestedSchema: request.params.requestedSchema,
|
|
},
|
|
originatingTab: currentTab,
|
|
resolve,
|
|
decline: (error: Error) => {
|
|
console.error("Elicitation request rejected:", error);
|
|
},
|
|
},
|
|
]);
|
|
|
|
setActiveTab("elicitations");
|
|
window.location.hash = "elicitations";
|
|
},
|
|
getRoots: () => rootsRef.current,
|
|
defaultLoggingLevel: logLevel,
|
|
metadata,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (serverCapabilities) {
|
|
const hash = window.location.hash.slice(1);
|
|
|
|
const validTabs = [
|
|
...(serverCapabilities?.resources ? ["resources"] : []),
|
|
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
|
...(serverCapabilities?.tools ? ["tools"] : []),
|
|
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
|
"apps",
|
|
"ping",
|
|
"sampling",
|
|
"elicitations",
|
|
"roots",
|
|
"auth",
|
|
"metadata",
|
|
];
|
|
|
|
const isValidTab = validTabs.includes(hash);
|
|
|
|
if (!isValidTab) {
|
|
const defaultTab = serverCapabilities?.resources
|
|
? "resources"
|
|
: serverCapabilities?.prompts
|
|
? "prompts"
|
|
: serverCapabilities?.tools
|
|
? "tools"
|
|
: serverCapabilities?.tasks
|
|
? "tasks"
|
|
: "ping";
|
|
|
|
setActiveTab(defaultTab);
|
|
window.location.hash = defaultTab;
|
|
}
|
|
}
|
|
}, [serverCapabilities]);
|
|
|
|
useEffect(() => {
|
|
if (mcpClient && activeTab === "tasks") {
|
|
void listTasks();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mcpClient, activeTab]);
|
|
|
|
useEffect(() => {
|
|
if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) {
|
|
void listTools();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mcpClient, activeTab, serverCapabilities?.tools]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastCommand", command);
|
|
}, [command]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastArgs", args);
|
|
}, [args]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastSseUrl", sseUrl);
|
|
}, [sseUrl]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastTransportType", transportType);
|
|
}, [transportType]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastConnectionType", connectionType);
|
|
}, [connectionType]);
|
|
|
|
useEffect(() => {
|
|
if (bearerToken) {
|
|
localStorage.setItem("lastBearerToken", bearerToken);
|
|
} else {
|
|
localStorage.removeItem("lastBearerToken");
|
|
}
|
|
}, [bearerToken]);
|
|
|
|
useEffect(() => {
|
|
if (headerName) {
|
|
localStorage.setItem("lastHeaderName", headerName);
|
|
} else {
|
|
localStorage.removeItem("lastHeaderName");
|
|
}
|
|
}, [headerName]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
|
|
}, [customHeaders]);
|
|
|
|
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
|
|
useEffect(() => {
|
|
if (customHeaders.length === 0 && (bearerToken || headerName)) {
|
|
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
|
|
if (migratedHeaders.length > 0) {
|
|
setCustomHeaders(migratedHeaders);
|
|
// Clear legacy auth after migration
|
|
setBearerToken("");
|
|
setHeaderName("");
|
|
}
|
|
}
|
|
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastOauthClientId", oauthClientId);
|
|
}, [oauthClientId]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastOauthScope", oauthScope);
|
|
}, [oauthScope]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("lastOauthClientSecret", oauthClientSecret);
|
|
}, [oauthClientSecret]);
|
|
|
|
useEffect(() => {
|
|
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
|
}, [config]);
|
|
|
|
const onOAuthConnect = useCallback(
|
|
(serverUrl: string) => {
|
|
setSseUrl(serverUrl);
|
|
setIsAuthDebuggerVisible(false);
|
|
void connectMcpServer();
|
|
},
|
|
[connectMcpServer],
|
|
);
|
|
|
|
const onOAuthDebugConnect = useCallback(
|
|
async ({
|
|
authorizationCode,
|
|
errorMsg,
|
|
restoredState,
|
|
}: {
|
|
authorizationCode?: string;
|
|
errorMsg?: string;
|
|
restoredState?: AuthDebuggerState;
|
|
}) => {
|
|
setIsAuthDebuggerVisible(true);
|
|
|
|
if (errorMsg) {
|
|
updateAuthState({
|
|
latestError: new Error(errorMsg),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (restoredState && authorizationCode) {
|
|
let currentState: AuthDebuggerState = {
|
|
...restoredState,
|
|
authorizationCode,
|
|
oauthStep: "token_request",
|
|
isInitiatingAuth: true,
|
|
statusMessage: null,
|
|
latestError: null,
|
|
};
|
|
|
|
try {
|
|
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
|
|
currentState = { ...currentState, ...updates };
|
|
});
|
|
|
|
while (
|
|
currentState.oauthStep !== "complete" &&
|
|
currentState.oauthStep !== "authorization_code"
|
|
) {
|
|
await stateMachine.executeStep(currentState);
|
|
}
|
|
|
|
if (currentState.oauthStep === "complete") {
|
|
updateAuthState({
|
|
...currentState,
|
|
statusMessage: {
|
|
type: "success",
|
|
message: "Authentication completed successfully",
|
|
},
|
|
isInitiatingAuth: false,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("OAuth continuation error:", error);
|
|
updateAuthState({
|
|
latestError:
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
statusMessage: {
|
|
type: "error",
|
|
message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
|
|
},
|
|
isInitiatingAuth: false,
|
|
});
|
|
}
|
|
} else if (authorizationCode) {
|
|
updateAuthState({
|
|
authorizationCode,
|
|
oauthStep: "token_request",
|
|
});
|
|
}
|
|
},
|
|
[sseUrl],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const loadOAuthTokens = async () => {
|
|
try {
|
|
if (sseUrl) {
|
|
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl);
|
|
const tokens = sessionStorage.getItem(key);
|
|
if (tokens) {
|
|
const parsedTokens = await OAuthTokensSchema.parseAsync(
|
|
JSON.parse(tokens),
|
|
);
|
|
updateAuthState({
|
|
oauthTokens: parsedTokens,
|
|
oauthStep: "complete",
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading OAuth tokens:", error);
|
|
}
|
|
};
|
|
|
|
loadOAuthTokens();
|
|
}, [sseUrl]);
|
|
|
|
useEffect(() => {
|
|
const headers: HeadersInit = {};
|
|
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
|
getMCPProxyAuthToken(config);
|
|
if (proxyAuthToken) {
|
|
headers[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
|
|
}
|
|
|
|
fetch(`${getMCPProxyAddress(config)}/config`, { headers })
|
|
.then((response) => response.json())
|
|
.then((data) => {
|
|
setEnv(data.defaultEnvironment);
|
|
if (data.defaultCommand) {
|
|
setCommand(data.defaultCommand);
|
|
}
|
|
if (data.defaultArgs) {
|
|
setArgs(data.defaultArgs);
|
|
}
|
|
if (data.defaultTransport) {
|
|
setTransportType(
|
|
data.defaultTransport as "stdio" | "sse" | "streamable-http",
|
|
);
|
|
}
|
|
if (data.defaultServerUrl) {
|
|
setSseUrl(data.defaultServerUrl);
|
|
}
|
|
})
|
|
.catch((error) =>
|
|
console.error("Error fetching default environment:", error),
|
|
);
|
|
}, [config]);
|
|
|
|
useEffect(() => {
|
|
rootsRef.current = roots;
|
|
}, [roots]);
|
|
|
|
useEffect(() => {
|
|
if (mcpClient && !window.location.hash) {
|
|
const defaultTab = serverCapabilities?.resources
|
|
? "resources"
|
|
: serverCapabilities?.prompts
|
|
? "prompts"
|
|
: serverCapabilities?.tools
|
|
? "tools"
|
|
: serverCapabilities?.tasks
|
|
? "tasks"
|
|
: "ping";
|
|
window.location.hash = defaultTab;
|
|
} else if (!mcpClient && window.location.hash) {
|
|
// Clear hash when disconnected - completely remove the fragment
|
|
window.history.replaceState(
|
|
null,
|
|
"",
|
|
window.location.pathname + window.location.search,
|
|
);
|
|
}
|
|
}, [mcpClient, serverCapabilities]);
|
|
|
|
useEffect(() => {
|
|
const handleHashChange = () => {
|
|
const hash = window.location.hash.slice(1);
|
|
if (hash && hash !== activeTab) {
|
|
setActiveTab(hash);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("hashchange", handleHashChange);
|
|
return () => window.removeEventListener("hashchange", handleHashChange);
|
|
}, [activeTab]);
|
|
|
|
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
|
setPendingSampleRequests((prev) => {
|
|
const request = prev.find((r) => r.id === id);
|
|
request?.resolve(result);
|
|
|
|
navigateToOriginatingTab(request?.originatingTab);
|
|
|
|
return prev.filter((r) => r.id !== id);
|
|
});
|
|
};
|
|
|
|
const handleRejectSampling = (id: number) => {
|
|
setPendingSampleRequests((prev) => {
|
|
const request = prev.find((r) => r.id === id);
|
|
request?.reject(new Error("Sampling request rejected"));
|
|
|
|
navigateToOriginatingTab(request?.originatingTab);
|
|
|
|
return prev.filter((r) => r.id !== id);
|
|
});
|
|
};
|
|
|
|
const handleResolveElicitation = (
|
|
id: number,
|
|
response: ElicitationResponse,
|
|
) => {
|
|
setPendingElicitationRequests((prev) => {
|
|
const request = prev.find((r) => r.id === id);
|
|
if (request) {
|
|
request.resolve(response);
|
|
|
|
if (request.originatingTab) {
|
|
const originatingTab = request.originatingTab;
|
|
|
|
const validTabs = [
|
|
...(serverCapabilities?.resources ? ["resources"] : []),
|
|
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
|
...(serverCapabilities?.tools ? ["tools"] : []),
|
|
...(serverCapabilities?.tasks ? ["tasks"] : []),
|
|
"apps",
|
|
"ping",
|
|
"sampling",
|
|
"elicitations",
|
|
"roots",
|
|
"auth",
|
|
"metadata",
|
|
];
|
|
|
|
if (validTabs.includes(originatingTab)) {
|
|
setActiveTab(originatingTab);
|
|
window.location.hash = originatingTab;
|
|
|
|
setTimeout(() => {
|
|
setActiveTab(originatingTab);
|
|
window.location.hash = originatingTab;
|
|
}, 100);
|
|
}
|
|
}
|
|
}
|
|
return prev.filter((r) => r.id !== id);
|
|
});
|
|
};
|
|
|
|
const clearError = (tabKey: keyof typeof errors) => {
|
|
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
|
};
|
|
|
|
const sendMCPRequest = async <T extends AnySchema>(
|
|
request: ClientRequest,
|
|
schema: T,
|
|
tabKey?: keyof typeof errors,
|
|
): Promise<SchemaOutput<T>> => {
|
|
try {
|
|
const response = await makeRequest(request, schema);
|
|
if (tabKey !== undefined) {
|
|
clearError(tabKey);
|
|
}
|
|
return response;
|
|
} catch (e) {
|
|
const errorString = (e as Error).message ?? String(e);
|
|
if (tabKey !== undefined) {
|
|
setErrors((prev) => ({
|
|
...prev,
|
|
[tabKey]: errorString,
|
|
}));
|
|
}
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const listResources = async () => {
|
|
const response = await sendMCPRequest(
|
|
{
|
|
method: "resources/list" as const,
|
|
params: nextResourceCursor ? { cursor: nextResourceCursor } : {},
|
|
},
|
|
ListResourcesResultSchema,
|
|
"resources",
|
|
);
|
|
setResources(resources.concat(response.resources ?? []));
|
|
setNextResourceCursor(response.nextCursor);
|
|
};
|
|
|
|
const listResourceTemplates = async () => {
|
|
const response = await sendMCPRequest(
|
|
{
|
|
method: "resources/templates/list" as const,
|
|
params: nextResourceTemplateCursor
|
|
? { cursor: nextResourceTemplateCursor }
|
|
: {},
|
|
},
|
|
ListResourceTemplatesResultSchema,
|
|
"resources",
|
|
);
|
|
setResourceTemplates(
|
|
resourceTemplates.concat(response.resourceTemplates ?? []),
|
|
);
|
|
setNextResourceTemplateCursor(response.nextCursor);
|
|
};
|
|
|
|
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
|
|
|
const response = await sendMCPRequest(
|
|
{
|
|
method: "prompts/get" as const,
|
|
params: { name, arguments: args },
|
|
},
|
|
GetPromptResultSchema,
|
|
"prompts",
|
|
);
|
|
setPromptContent(JSON.stringify(response, null, 2));
|
|
};
|
|
|
|
const readResource = async (uri: string) => {
|
|
if (fetchingResources.has(uri) || resourceContentMap[uri]) {
|
|
return;
|
|
}
|
|
|
|
console.log("[App] Reading resource:", uri);
|
|
setFetchingResources((prev) => new Set(prev).add(uri));
|
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
|
|
|
try {
|
|
const response = await sendMCPRequest(
|
|
{
|
|
method: "resources/read" as const,
|
|
params: { uri },
|
|
},
|
|
ReadResourceResultSchema,
|
|
"resources",
|
|
);
|
|
console.log("[App] Resource read response:", {
|
|
uri,
|
|
responseLength: JSON.stringify(response).length,
|
|
hasContents: !!(response as { contents?: unknown[] }).contents,
|
|
});
|
|
const content = JSON.stringify(response, null, 2);
|
|
setResourceContent(content);
|
|
setResourceContentMap((prev) => ({
|
|
...prev,
|
|
[uri]: content,
|
|
}));
|
|
} catch (error) {
|
|
console.error(`[App] Failed to read resource ${uri}:`, error);
|
|
const errorString = (error as Error).message ?? String(error);
|
|
setResourceContentMap((prev) => ({
|
|
...prev,
|
|
[uri]: JSON.stringify({ error: errorString }),
|
|
}));
|
|
} finally {
|
|
setFetchingResources((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(uri);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const subscribeToResource = async (uri: string) => {
|
|
if (!resourceSubscriptions.has(uri)) {
|
|
await sendMCPRequest(
|
|
{
|
|
method: "resources/subscribe" as const,
|
|
params: { uri },
|
|
},
|
|
z.object({}),
|
|
"resources",
|
|
);
|
|
const clone = new Set(resourceSubscriptions);
|
|
clone.add(uri);
|
|
setResourceSubscriptions(clone);
|
|
}
|
|
};
|
|
|
|
const unsubscribeFromResource = async (uri: string) => {
|
|
if (resourceSubscriptions.has(uri)) {
|
|
await sendMCPRequest(
|
|
{
|
|
method: "resources/unsubscribe" as const,
|
|
params: { uri },
|
|
},
|
|
z.object({}),
|
|
"resources",
|
|
);
|
|
const clone = new Set(resourceSubscriptions);
|
|
clone.delete(uri);
|
|
setResourceSubscriptions(clone);
|
|
}
|
|
};
|
|
|
|
const listPrompts = async () => {
|
|
const response = await sendMCPRequest(
|
|
{
|
|
method: "prompts/list" as const,
|
|
params: nextPromptCursor ? { cursor: nextPromptCursor } : {},
|
|
},
|
|
ListPromptsResultSchema,
|
|
"prompts",
|
|
);
|
|
setPrompts(response.prompts);
|
|
setNextPromptCursor(response.nextCursor);
|
|
};
|
|
|
|
const listTools = async () => {
|
|
const response = await sendMCPRequest(
|
|
{
|
|
method: "tools/list" as const,
|
|
params: nextToolCursor ? { cursor: nextToolCursor } : {},
|
|
},
|
|
ListToolsResultSchema,
|
|
"tools",
|
|
);
|
|
setTools(response.tools);
|
|
setNextToolCursor(response.nextCursor);
|
|
cacheToolOutputSchemas(response.tools);
|
|
};
|
|
|
|
const callTool = async (
|
|
name: string,
|
|
params: Record<string, unknown>,
|
|
toolMetadata?: Record<string, unknown>,
|
|
runAsTask?: boolean,
|
|
): Promise<CompatibilityCallToolResult> => {
|
|
lastToolCallOriginTabRef.current = currentTabRef.current;
|
|
|
|
try {
|
|
// Find the tool schema to clean parameters properly
|
|
const tool = tools.find((t) => t.name === name);
|
|
const cleanedParams = tool?.inputSchema
|
|
? cleanParams(params, tool.inputSchema as JsonSchemaType)
|
|
: params;
|
|
|
|
// Merge general metadata with tool-specific metadata
|
|
// Tool-specific metadata takes precedence over general metadata
|
|
const mergedMetadata = {
|
|
...metadata, // General metadata
|
|
progressToken: progressTokenRef.current++,
|
|
...toolMetadata, // Tool-specific metadata
|
|
};
|
|
|
|
const request: ClientRequest = {
|
|
method: "tools/call" as const,
|
|
params: {
|
|
name,
|
|
arguments: cleanedParams,
|
|
_meta: mergedMetadata,
|
|
},
|
|
};
|
|
|
|
if (runAsTask) {
|
|
request.params = {
|
|
...request.params,
|
|
task: {
|
|
ttl: getMCPTaskTtl(config),
|
|
},
|
|
};
|
|
}
|
|
|
|
const response = await sendMCPRequest(
|
|
request,
|
|
CompatibilityCallToolResultSchema,
|
|
"tools",
|
|
);
|
|
|
|
// Check if this was a task-augmented request that returned a task reference
|
|
// The server returns { task: { taskId, status, ... } } when a task is created
|
|
const isTaskResult = (
|
|
res: unknown,
|
|
): res is {
|
|
task: { taskId: string; status: string; pollInterval: number };
|
|
} =>
|
|
!!res &&
|
|
typeof res === "object" &&
|
|
"task" in res &&
|
|
!!res.task &&
|
|
typeof res.task === "object" &&
|
|
"taskId" in res.task;
|
|
|
|
if (runAsTask && isTaskResult(response)) {
|
|
const taskId = response.task.taskId;
|
|
const pollInterval = response.task.pollInterval;
|
|
// Set polling state BEFORE setting tool result for proper UI update
|
|
setIsPollingTask(true);
|
|
// Safely extract any _meta from the original response (if present)
|
|
const initialResponseMeta =
|
|
response &&
|
|
typeof response === "object" &&
|
|
"_meta" in (response as Record<string, unknown>)
|
|
? ((response as { _meta?: Record<string, unknown> })._meta ?? {})
|
|
: undefined;
|
|
let latestToolResult: CompatibilityCallToolResult = {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Task created: ${taskId}. Polling for status...`,
|
|
},
|
|
],
|
|
_meta: {
|
|
...(initialResponseMeta || {}),
|
|
"io.modelcontextprotocol/related-task": { taskId },
|
|
},
|
|
};
|
|
setToolResult(latestToolResult);
|
|
|
|
// Polling loop
|
|
let taskCompleted = false;
|
|
while (!taskCompleted) {
|
|
try {
|
|
// Wait for 1 second before polling
|
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
|
|
const taskStatus = await sendMCPRequest(
|
|
{
|
|
method: "tasks/get",
|
|
params: { taskId },
|
|
},
|
|
GetTaskResultSchema,
|
|
);
|
|
|
|
if (
|
|
taskStatus.status === "completed" ||
|
|
taskStatus.status === "failed" ||
|
|
taskStatus.status === "cancelled"
|
|
) {
|
|
taskCompleted = true;
|
|
console.log(
|
|
`Polling complete for task ${taskId}: ${taskStatus.status}`,
|
|
);
|
|
|
|
if (taskStatus.status === "completed") {
|
|
console.log(`Fetching result for task ${taskId}`);
|
|
const result = await sendMCPRequest(
|
|
{
|
|
method: "tasks/result",
|
|
params: { taskId },
|
|
},
|
|
CompatibilityCallToolResultSchema,
|
|
);
|
|
console.log(`Result received for task ${taskId}:`, result);
|
|
latestToolResult = result as CompatibilityCallToolResult;
|
|
setToolResult(latestToolResult);
|
|
|
|
// Refresh tasks list to show completed state
|
|
void listTasks();
|
|
} else {
|
|
latestToolResult = {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Task ${taskStatus.status}: ${taskStatus.statusMessage || "No additional information"}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
setToolResult(latestToolResult);
|
|
// Refresh tasks list to show failed/cancelled state
|
|
void listTasks();
|
|
}
|
|
} else {
|
|
// Update status message while polling
|
|
// Safely extract any _meta from the original response (if present)
|
|
const pollingResponseMeta =
|
|
response &&
|
|
typeof response === "object" &&
|
|
"_meta" in (response as Record<string, unknown>)
|
|
? ((response as { _meta?: Record<string, unknown> })._meta ??
|
|
{})
|
|
: undefined;
|
|
latestToolResult = {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Task status: ${taskStatus.status}${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Polling...`,
|
|
},
|
|
],
|
|
_meta: {
|
|
...(pollingResponseMeta || {}),
|
|
"io.modelcontextprotocol/related-task": { taskId },
|
|
},
|
|
};
|
|
setToolResult(latestToolResult);
|
|
// Refresh tasks list to show progress
|
|
void listTasks();
|
|
}
|
|
} catch (pollingError) {
|
|
console.error("Error polling task status:", pollingError);
|
|
latestToolResult = {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Error polling task status: ${pollingError instanceof Error ? pollingError.message : String(pollingError)}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
setToolResult(latestToolResult);
|
|
taskCompleted = true;
|
|
}
|
|
}
|
|
setIsPollingTask(false);
|
|
// Clear any validation errors since tool execution completed
|
|
setErrors((prev) => ({ ...prev, tools: null }));
|
|
return latestToolResult;
|
|
} else {
|
|
const directResult = response as CompatibilityCallToolResult;
|
|
setToolResult(directResult);
|
|
// Clear any validation errors since tool execution completed
|
|
setErrors((prev) => ({ ...prev, tools: null }));
|
|
return directResult;
|
|
}
|
|
} catch (e) {
|
|
const toolResult: CompatibilityCallToolResult = {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: (e as Error).message ?? String(e),
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
setToolResult(toolResult);
|
|
// Clear validation errors - tool execution errors are shown in ToolResults
|
|
setErrors((prev) => ({ ...prev, tools: null }));
|
|
return toolResult;
|
|
}
|
|
};
|
|
|
|
const listTasks = useCallback(async () => {
|
|
try {
|
|
const response = await listMcpTasks(nextTaskCursor);
|
|
setTasks(response.tasks);
|
|
setNextTaskCursor(response.nextCursor);
|
|
// Inline error clear to avoid extra dependency on clearError
|
|
setErrors((prev) => ({ ...prev, tasks: null }));
|
|
} catch (e) {
|
|
setErrors((prev) => ({
|
|
...prev,
|
|
tasks: (e as Error).message ?? String(e),
|
|
}));
|
|
}
|
|
}, [listMcpTasks, nextTaskCursor]);
|
|
|
|
const cancelTask = async (taskId: string) => {
|
|
try {
|
|
const response = await cancelMcpTask(taskId);
|
|
setTasks((prev) => prev.map((t) => (t.taskId === taskId ? response : t)));
|
|
if (selectedTask?.taskId === taskId) {
|
|
setSelectedTask(response);
|
|
}
|
|
clearError("tasks");
|
|
} catch (e) {
|
|
setErrors((prev) => ({
|
|
...prev,
|
|
tasks: (e as Error).message ?? String(e),
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleRootsChange = async () => {
|
|
await sendNotification({ method: "notifications/roots/list_changed" });
|
|
};
|
|
|
|
const handleClearNotifications = () => {
|
|
setNotifications([]);
|
|
};
|
|
|
|
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
|
await sendMCPRequest(
|
|
{
|
|
method: "logging/setLevel" as const,
|
|
params: { level },
|
|
},
|
|
z.object({}),
|
|
);
|
|
setLogLevel(level);
|
|
};
|
|
|
|
const AuthDebuggerWrapper = () => (
|
|
<TabsContent value="auth">
|
|
<AuthDebugger
|
|
serverUrl={sseUrl}
|
|
onBack={() => setIsAuthDebuggerVisible(false)}
|
|
authState={authState}
|
|
updateAuthState={updateAuthState}
|
|
/>
|
|
</TabsContent>
|
|
);
|
|
|
|
if (window.location.pathname === "/oauth/callback") {
|
|
const OAuthCallback = React.lazy(
|
|
() => import("./components/OAuthCallback"),
|
|
);
|
|
return (
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<OAuthCallback onConnect={onOAuthConnect} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
if (window.location.pathname === "/oauth/callback/debug") {
|
|
const OAuthDebugCallback = React.lazy(
|
|
() => import("./components/OAuthDebugCallback"),
|
|
);
|
|
return (
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<OAuthDebugCallback onConnect={onOAuthDebugConnect} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen bg-background">
|
|
<div
|
|
style={{
|
|
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
|
|
onMouseDown={handleSidebarDragStart}
|
|
style={{
|
|
cursor: "col-resize",
|
|
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) => {
|
|
clearError("resources");
|
|
readResource(uri);
|
|
}}
|
|
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}
|
|
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`,
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
export default App;
|