Merge branch 'main' into oleina/logging

This commit is contained in:
Ola Hungerford 2025-07-26 10:51:01 -07:00 • committed by GitHub
commit e573e855f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 212 additions and 3 deletions

View file

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

View file

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

View file

@ -466,10 +466,24 @@ const App = () => {
}, [roots]);
useEffect(() => {
if (!window.location.hash) {
window.location.hash = "resources";
if (mcpClient && !window.location.hash) {
const defaultTab = serverCapabilities?.resources
? "resources"
: serverCapabilities?.prompts
? "prompts"
: serverCapabilities?.tools
? "tools"
: "ping";
window.location.hash = defaultTab;
} else if (!mcpClient && window.location.hash) {
// Clear hash when disconnected - completely remove the fragment
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
}, []);
}, [mcpClient, serverCapabilities]);
useEffect(() => {
const handleHashChange = () => {

View file

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

View file

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

View file

@ -52,6 +52,7 @@ const ResourcesTab = ({
ref: ResourceReference | PromptReference,
argName: string,
value: string,
context?: Record<string, string>,
) => Promise<string[]>;
completionsSupported: boolean;
resourceContent: string;
@ -94,6 +95,7 @@ const ResourcesTab = ({
},
key,
value,
templateValues,
);
}
};

View file

@ -28,6 +28,7 @@ export function useCompletionState(
ref: ResourceReference | PromptReference,
argName: string,
value: string,
context?: Record<string, string>,
signal?: AbortSignal,
) => Promise<string[]>,
completionsSupported: boolean = true,
@ -66,6 +67,7 @@ export function useCompletionState(
ref: ResourceReference | PromptReference,
argName: string,
value: string,
context?: Record<string, string>,
) => {
if (!completionsSupported) {
return;
@ -82,10 +84,15 @@ export function useCompletionState(
}));
try {
if (context !== undefined) {
delete context[argName];
}
const values = await handleCompletion(
ref,
argName,
value,
context,
abortController.signal,
);
@ -97,6 +104,7 @@ export function useCompletionState(
}));
}
} catch {
console.error("completion failed");
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,

View file

@ -201,6 +201,7 @@ export function useConnection({
ref: ResourceReference | PromptReference,
argName: string,
value: string,
context?: Record<string, string>,
signal?: AbortSignal,
): Promise<string[]> => {
if (!mcpClient || !completionsSupported) {
@ -218,6 +219,12 @@ export function useConnection({
},
};
if (context) {
request["params"]["context"] = {
arguments: context,
};
}
try {
const response = await makeRequest(request, CompleteResultSchema, {
signal,