Merge branch 'main' into oleina/logging
This commit is contained in:
commit
e573e855f0
8 changed files with 212 additions and 3 deletions
16
client/e2e/startup-state.spec.ts
Normal file
16
client/e2e/startup-state.spec.ts
Normal 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("#");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,4 +32,5 @@ module.exports = {
|
||||||
"/e2e/",
|
"/e2e/",
|
||||||
"\\.config\\.(js|ts|cjs|mjs)$",
|
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||||
],
|
],
|
||||||
|
randomize: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -466,10 +466,24 @@ const App = () => {
|
||||||
}, [roots]);
|
}, [roots]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.location.hash) {
|
if (mcpClient && !window.location.hash) {
|
||||||
window.location.hash = "resources";
|
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(() => {
|
useEffect(() => {
|
||||||
const handleHashChange = () => {
|
const handleHashChange = () => {
|
||||||
|
|
|
||||||
159
client/src/__tests__/App.routing.test.tsx
Normal file
159
client/src/__tests__/App.routing.test.tsx
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -48,6 +48,7 @@ const PromptsTab = ({
|
||||||
ref: PromptReference | ResourceReference,
|
ref: PromptReference | ResourceReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
context?: Record<string, string>,
|
||||||
) => Promise<string[]>;
|
) => Promise<string[]>;
|
||||||
completionsSupported: boolean;
|
completionsSupported: boolean;
|
||||||
promptContent: string;
|
promptContent: string;
|
||||||
|
|
@ -73,6 +74,7 @@ const PromptsTab = ({
|
||||||
},
|
},
|
||||||
argName,
|
argName,
|
||||||
value,
|
value,
|
||||||
|
promptArgs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const ResourcesTab = ({
|
||||||
ref: ResourceReference | PromptReference,
|
ref: ResourceReference | PromptReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
context?: Record<string, string>,
|
||||||
) => Promise<string[]>;
|
) => Promise<string[]>;
|
||||||
completionsSupported: boolean;
|
completionsSupported: boolean;
|
||||||
resourceContent: string;
|
resourceContent: string;
|
||||||
|
|
@ -94,6 +95,7 @@ const ResourcesTab = ({
|
||||||
},
|
},
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
|
templateValues,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export function useCompletionState(
|
||||||
ref: ResourceReference | PromptReference,
|
ref: ResourceReference | PromptReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
context?: Record<string, string>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => Promise<string[]>,
|
) => Promise<string[]>,
|
||||||
completionsSupported: boolean = true,
|
completionsSupported: boolean = true,
|
||||||
|
|
@ -66,6 +67,7 @@ export function useCompletionState(
|
||||||
ref: ResourceReference | PromptReference,
|
ref: ResourceReference | PromptReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
context?: Record<string, string>,
|
||||||
) => {
|
) => {
|
||||||
if (!completionsSupported) {
|
if (!completionsSupported) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -82,10 +84,15 @@ export function useCompletionState(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (context !== undefined) {
|
||||||
|
delete context[argName];
|
||||||
|
}
|
||||||
|
|
||||||
const values = await handleCompletion(
|
const values = await handleCompletion(
|
||||||
ref,
|
ref,
|
||||||
argName,
|
argName,
|
||||||
value,
|
value,
|
||||||
|
context,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -97,6 +104,7 @@ export function useCompletionState(
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
console.error("completion failed");
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ export function useConnection({
|
||||||
ref: ResourceReference | PromptReference,
|
ref: ResourceReference | PromptReference,
|
||||||
argName: string,
|
argName: string,
|
||||||
value: string,
|
value: string,
|
||||||
|
context?: Record<string, string>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<string[]> => {
|
): Promise<string[]> => {
|
||||||
if (!mcpClient || !completionsSupported) {
|
if (!mcpClient || !completionsSupported) {
|
||||||
|
|
@ -218,6 +219,12 @@ export function useConnection({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
request["params"]["context"] = {
|
||||||
|
arguments: context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await makeRequest(request, CompleteResultSchema, {
|
const response = await makeRequest(request, CompleteResultSchema, {
|
||||||
signal,
|
signal,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue