diff --git a/client/e2e/startup-state.spec.ts b/client/e2e/startup-state.spec.ts
new file mode 100644
index 00000000..414ebd3e
--- /dev/null
+++ b/client/e2e/startup-state.spec.ts
@@ -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("#");
+ });
+});
diff --git a/client/jest.config.cjs b/client/jest.config.cjs
index 8652eda5..9b793eae 100644
--- a/client/jest.config.cjs
+++ b/client/jest.config.cjs
@@ -32,4 +32,5 @@ module.exports = {
"/e2e/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
+ randomize: true,
};
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 12e78fa8..537f80bf 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -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 = () => {
diff --git a/client/src/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx
new file mode 100644
index 00000000..dbd463f9
--- /dev/null
+++ b/client/src/__tests__/App.routing.test.tsx
@@ -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: () =>
Sidebar
,
+}));
+
+// 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();
+
+ 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();
+
+ for (const { capabilities, expected } of testCases) {
+ window.location.hash = "";
+ mockUseConnection.mockReturnValue({
+ ...connectedConnectionState,
+ serverCapabilities: capabilities,
+ });
+
+ rerender();
+
+ 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();
+
+ // Should clear the hash when disconnected
+ await waitFor(() => {
+ expect(window.location.hash).toBe("");
+ });
+ });
+});
diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx
index 466b3d52..fa590558 100644
--- a/client/src/components/PromptsTab.tsx
+++ b/client/src/components/PromptsTab.tsx
@@ -48,6 +48,7 @@ const PromptsTab = ({
ref: PromptReference | ResourceReference,
argName: string,
value: string,
+ context?: Record,
) => Promise;
completionsSupported: boolean;
promptContent: string;
@@ -73,6 +74,7 @@ const PromptsTab = ({
},
argName,
value,
+ promptArgs,
);
}
};
diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx
index 8ec3f91b..874e0ff7 100644
--- a/client/src/components/ResourcesTab.tsx
+++ b/client/src/components/ResourcesTab.tsx
@@ -52,6 +52,7 @@ const ResourcesTab = ({
ref: ResourceReference | PromptReference,
argName: string,
value: string,
+ context?: Record,
) => Promise;
completionsSupported: boolean;
resourceContent: string;
@@ -94,6 +95,7 @@ const ResourcesTab = ({
},
key,
value,
+ templateValues,
);
}
};
diff --git a/client/src/lib/hooks/useCompletionState.ts b/client/src/lib/hooks/useCompletionState.ts
index 71492e31..26b69a27 100644
--- a/client/src/lib/hooks/useCompletionState.ts
+++ b/client/src/lib/hooks/useCompletionState.ts
@@ -28,6 +28,7 @@ export function useCompletionState(
ref: ResourceReference | PromptReference,
argName: string,
value: string,
+ context?: Record,
signal?: AbortSignal,
) => Promise,
completionsSupported: boolean = true,
@@ -66,6 +67,7 @@ export function useCompletionState(
ref: ResourceReference | PromptReference,
argName: string,
value: string,
+ context?: Record,
) => {
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,
diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts
index db0df3a0..268ba8ff 100644
--- a/client/src/lib/hooks/useConnection.ts
+++ b/client/src/lib/hooks/useConnection.ts
@@ -201,6 +201,7 @@ export function useConnection({
ref: ResourceReference | PromptReference,
argName: string,
value: string,
+ context?: Record,
signal?: AbortSignal,
): Promise => {
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,