Merge branch 'main' into alejandro.borbolla/copy-input
This commit is contained in:
commit
3075cbbf18
14 changed files with 815 additions and 188 deletions
|
|
@ -200,6 +200,7 @@ const App = () => {
|
|||
serverCapabilities,
|
||||
mcpClient,
|
||||
requestHistory,
|
||||
clearRequestHistory,
|
||||
makeRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
|
|
@ -736,6 +737,10 @@ const App = () => {
|
|||
await sendNotification({ method: "notifications/roots/list_changed" });
|
||||
};
|
||||
|
||||
const handleClearNotifications = () => {
|
||||
setNotifications([]);
|
||||
};
|
||||
|
||||
const sendLogLevelRequest = async (level: LoggingLevel) => {
|
||||
await sendMCPRequest(
|
||||
{
|
||||
|
|
@ -1102,6 +1107,8 @@ const App = () => {
|
|||
<HistoryAndNotifications
|
||||
requestHistory={requestHistory}
|
||||
serverNotifications={notifications}
|
||||
onClearHistory={clearRequestHistory}
|
||||
onClearNotifications={handleClearNotifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ jest.mock("../lib/hooks/useConnection", () => ({
|
|||
serverCapabilities: null,
|
||||
mcpClient: null,
|
||||
requestHistory: [],
|
||||
clearRequestHistory: jest.fn(),
|
||||
makeRequest: jest.fn(),
|
||||
sendNotification: jest.fn(),
|
||||
handleCompletion: jest.fn(),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const disconnectedConnectionState = {
|
|||
serverCapabilities: null,
|
||||
mcpClient: null,
|
||||
requestHistory: [],
|
||||
clearRequestHistory: jest.fn(),
|
||||
makeRequest: jest.fn(),
|
||||
sendNotification: jest.fn(),
|
||||
handleCompletion: jest.fn(),
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ const AuthDebugger = ({
|
|||
JSON.stringify(currentState),
|
||||
);
|
||||
// Open the authorization URL automatically
|
||||
window.location.href = currentState.authorizationUrl;
|
||||
window.location.href = currentState.authorizationUrl.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useState } from "react";
|
||||
import JsonView from "./JsonView";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const HistoryAndNotifications = ({
|
||||
requestHistory,
|
||||
serverNotifications,
|
||||
onClearHistory,
|
||||
onClearNotifications,
|
||||
}: {
|
||||
requestHistory: Array<{ request: string; response?: string }>;
|
||||
serverNotifications: ServerNotification[];
|
||||
onClearHistory?: () => void;
|
||||
onClearNotifications?: () => void;
|
||||
}) => {
|
||||
const [expandedRequests, setExpandedRequests] = useState<{
|
||||
[key: number]: boolean;
|
||||
|
|
@ -27,7 +32,17 @@ const HistoryAndNotifications = ({
|
|||
return (
|
||||
<div className="bg-card overflow-hidden flex h-full">
|
||||
<div className="flex-1 overflow-y-auto p-4 border-r">
|
||||
<h2 className="text-lg font-semibold mb-4">History</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">History</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearHistory}
|
||||
disabled={requestHistory.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{requestHistory.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No history yet
|
||||
|
|
@ -93,7 +108,17 @@ const HistoryAndNotifications = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Server Notifications</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearNotifications}
|
||||
disabled={serverNotifications.length === 0}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{serverNotifications.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No notifications yet
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ export const OAuthFlowProgress = ({
|
|||
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs break-all">
|
||||
{authState.authorizationUrl}
|
||||
{String(authState.authorizationUrl)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,9 @@ const Sidebar = ({
|
|||
description:
|
||||
transportType === "stdio"
|
||||
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
|
||||
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||
: transportType === "streamable-http"
|
||||
? "Streamable HTTP URL has been copied. Use this URL directly in your MCP Client."
|
||||
: "SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -482,7 +482,9 @@ describe("AuthDebugger", () => {
|
|||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.stringContaining("scope="),
|
||||
authorizationUrl: expect.objectContaining({
|
||||
href: "https://oauth.example.com/authorize?scope=read+write",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -496,7 +498,9 @@ describe("AuthDebugger", () => {
|
|||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.stringContaining("scope="),
|
||||
authorizationUrl: expect.objectContaining({
|
||||
href: "https://oauth.example.com/authorize?scope=read+write",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, jest } from "@jest/globals";
|
||||
import HistoryAndNotifications from "../HistoryAndNotifications";
|
||||
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
|
@ -223,4 +224,66 @@ describe("HistoryAndNotifications", () => {
|
|||
expect(screen.getByText("No history yet")).toBeTruthy();
|
||||
expect(screen.getByText("No notifications yet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears request history when Clear is clicked", () => {
|
||||
const Wrapper = () => {
|
||||
const [history, setHistory] = useState(mockRequestHistory);
|
||||
return (
|
||||
<HistoryAndNotifications
|
||||
requestHistory={history}
|
||||
serverNotifications={[]}
|
||||
onClearHistory={() => setHistory([])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
// Verify items are present initially
|
||||
expect(screen.getByText("2. test/method2")).toBeTruthy();
|
||||
expect(screen.getByText("1. test/method1")).toBeTruthy();
|
||||
|
||||
// Click Clear in History header (scoped by the History heading's container)
|
||||
const historyHeader = screen.getByText("History");
|
||||
const historyHeaderContainer = historyHeader.parentElement as HTMLElement;
|
||||
const historyClearButton = within(historyHeaderContainer).getByRole(
|
||||
"button",
|
||||
{ name: "Clear" },
|
||||
);
|
||||
fireEvent.click(historyClearButton);
|
||||
|
||||
// History should now be empty
|
||||
expect(screen.getByText("No history yet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears server notifications when Clear is clicked", () => {
|
||||
const Wrapper = () => {
|
||||
const [notifications, setNotifications] =
|
||||
useState<ServerNotification[]>(mockNotifications);
|
||||
return (
|
||||
<HistoryAndNotifications
|
||||
requestHistory={[]}
|
||||
serverNotifications={notifications}
|
||||
onClearNotifications={() => setNotifications([])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
// Verify items are present initially
|
||||
expect(screen.getByText("2. notifications/progress")).toBeTruthy();
|
||||
expect(screen.getByText("1. notifications/message")).toBeTruthy();
|
||||
|
||||
// Click Clear in Server Notifications header (scoped by its heading's container)
|
||||
const notifHeader = screen.getByText("Server Notifications");
|
||||
const notifHeaderContainer = notifHeader.parentElement as HTMLElement;
|
||||
const notifClearButton = within(notifHeaderContainer).getByRole("button", {
|
||||
name: "Clear",
|
||||
});
|
||||
fireEvent.click(notifClearButton);
|
||||
|
||||
// Notifications should now be empty
|
||||
expect(screen.getByText("No notifications yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -446,6 +446,11 @@ describe("Sidebar", () => {
|
|||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Config entry copied",
|
||||
description:
|
||||
"Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
|
||||
|
|
@ -504,6 +509,11 @@ describe("Sidebar", () => {
|
|||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Config entry copied",
|
||||
description:
|
||||
"SSE URL has been copied. Use this URL directly in your MCP Client.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for SSE transport", async () => {
|
||||
|
|
@ -554,6 +564,11 @@ describe("Sidebar", () => {
|
|||
4,
|
||||
);
|
||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
title: "Config entry copied",
|
||||
description:
|
||||
"Streamable HTTP URL has been copied. Use this URL directly in your MCP Client.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export interface AuthDebuggerState {
|
|||
authServerUrl: URL | null;
|
||||
oauthMetadata: OAuthMetadata | null;
|
||||
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
|
||||
authorizationUrl: string | null;
|
||||
authorizationUrl: URL | null;
|
||||
authorizationCode: string;
|
||||
latestError: Error | null;
|
||||
statusMessage: StatusMessage | null;
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ export function useConnection({
|
|||
// Add progress notification to `Server Notification` window in the UI
|
||||
if (onNotification) {
|
||||
onNotification({
|
||||
method: "notification/progress",
|
||||
method: "notifications/progress",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
|
@ -599,6 +599,15 @@ export function useConnection({
|
|||
if (capabilities?.logging && defaultLoggingLevel) {
|
||||
lastRequest = "logging/setLevel";
|
||||
await client.setLoggingLevel(defaultLoggingLevel);
|
||||
pushHistory(
|
||||
{
|
||||
method: "logging/setLevel",
|
||||
params: {
|
||||
level: defaultLoggingLevel,
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
lastRequest = "";
|
||||
}
|
||||
|
||||
|
|
@ -644,11 +653,16 @@ export function useConnection({
|
|||
setServerCapabilities(null);
|
||||
};
|
||||
|
||||
const clearRequestHistory = () => {
|
||||
setRequestHistory([]);
|
||||
};
|
||||
|
||||
return {
|
||||
connectionStatus,
|
||||
serverCapabilities,
|
||||
mcpClient,
|
||||
requestHistory,
|
||||
clearRequestHistory,
|
||||
makeRequest,
|
||||
sendNotification,
|
||||
handleCompletion,
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
|
||||
context.provider.saveCodeVerifier(codeVerifier);
|
||||
context.updateState({
|
||||
authorizationUrl: authorizationUrl.toString(),
|
||||
authorizationUrl: authorizationUrl,
|
||||
oauthStep: "authorization_code",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* @param url - The URL string to validate
|
||||
* @throws Error if the URL has an unsafe protocol
|
||||
*/
|
||||
export function validateRedirectUrl(url: string): void {
|
||||
export function validateRedirectUrl(url: string | URL): void {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
|
|
|
|||
Loading…
Reference in a new issue