This commit is contained in:
Ola Hungerford
2025-02-14 06:07:46 -07:00
10 changed files with 150 additions and 38 deletions

View File

@@ -11,7 +11,7 @@ The MCP inspector is a developer tool for testing and debugging MCP servers.
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:
```bash
npx @modelcontextprotocol/inspector build/index.js
npx @modelcontextprotocol/inspector node build/index.js
```
You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag:
@@ -21,19 +21,19 @@ You can pass both arguments and environment variables to your MCP server. Argume
npx @modelcontextprotocol/inspector build/index.js arg1 arg2
# Pass environment variables only
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js
# Pass both environment variables and arguments
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2
npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2
# Use -- to separate inspector flags from server arguments
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag
npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag
```
The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed:
```bash
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js
CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js
```
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.4.0",
"version": "0.4.1",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View File

@@ -24,9 +24,15 @@ const OAuthCallback = () => {
}
try {
const accessToken = await handleOAuthCallback(serverUrl, code);
// Store the access token for future use
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, accessToken);
const tokens = await handleOAuthCallback(serverUrl, code);
// Store both access and refresh tokens
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token);
if (tokens.refresh_token) {
sessionStorage.setItem(
SESSION_KEYS.REFRESH_TOKEN,
tokens.refresh_token,
);
}
// Redirect back to the main app with server URL to trigger auto-connect
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
} catch (error) {

View File

@@ -87,11 +87,20 @@ const ToolsTab = ({
className="max-w-full h-auto"
/>
)}
{item.type === "resource" && (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(item.resource, null, 2)}
</pre>
)}
{item.type === "resource" &&
(item.resource?.mimeType?.startsWith("audio/") ? (
<audio
controls
src={`data:${item.resource.mimeType};base64,${item.resource.blob}`}
className="w-full"
>
<p>Your browser does not support audio playback</p>
</audio>
) : (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(item.resource, null, 2)}
</pre>
))}
</div>
))}
</>

View File

@@ -1,10 +1,21 @@
import pkceChallenge from "pkce-challenge";
import { SESSION_KEYS } from "./constants";
import { z } from "zod";
export interface OAuthMetadata {
authorization_endpoint: string;
token_endpoint: string;
}
export const OAuthMetadataSchema = z.object({
authorization_endpoint: z.string(),
token_endpoint: z.string(),
});
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
export const OAuthTokensSchema = z.object({
access_token: z.string(),
refresh_token: z.string().optional(),
expires_in: z.number().optional(),
});
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
export async function discoverOAuthMetadata(
serverUrl: string,
@@ -15,10 +26,11 @@ export async function discoverOAuthMetadata(
if (response.ok) {
const metadata = await response.json();
return {
const validatedMetadata = OAuthMetadataSchema.parse({
authorization_endpoint: metadata.authorization_endpoint,
token_endpoint: metadata.token_endpoint,
};
});
return validatedMetadata;
}
} catch (error) {
console.warn("OAuth metadata discovery failed:", error);
@@ -26,10 +38,11 @@ export async function discoverOAuthMetadata(
// Fall back to default endpoints
const baseUrl = new URL(serverUrl);
return {
const defaultMetadata = {
authorization_endpoint: new URL("/authorize", baseUrl).toString(),
token_endpoint: new URL("/token", baseUrl).toString(),
};
return OAuthMetadataSchema.parse(defaultMetadata);
}
export async function startOAuthFlow(serverUrl: string): Promise<string> {
@@ -60,7 +73,7 @@ export async function startOAuthFlow(serverUrl: string): Promise<string> {
export async function handleOAuthCallback(
serverUrl: string,
code: string,
): Promise<string> {
): Promise<OAuthTokens> {
// Get stored code verifier
const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
if (!codeVerifier) {
@@ -69,7 +82,6 @@ export async function handleOAuthCallback(
// Discover OAuth endpoints
const metadata = await discoverOAuthMetadata(serverUrl);
// Exchange code for tokens
const response = await fetch(metadata.token_endpoint, {
method: "POST",
@@ -88,6 +100,35 @@ export async function handleOAuthCallback(
throw new Error("Token exchange failed");
}
const data = await response.json();
return data.access_token;
const tokens = await response.json();
return OAuthTokensSchema.parse(tokens);
}
export async function refreshAccessToken(
serverUrl: string,
): Promise<OAuthTokens> {
const refreshToken = sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN);
if (!refreshToken) {
throw new Error("No refresh token available");
}
const metadata = await discoverOAuthMetadata(serverUrl);
const response = await fetch(metadata.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
throw new Error("Token refresh failed");
}
const tokens = await response.json();
return OAuthTokensSchema.parse(tokens);
}

View File

@@ -3,4 +3,5 @@ export const SESSION_KEYS = {
CODE_VERIFIER: "mcp_code_verifier",
SERVER_URL: "mcp_server_url",
ACCESS_TOKEN: "mcp_access_token",
REFRESH_TOKEN: "mcp_refresh_token",
} as const;

View File

@@ -16,7 +16,7 @@ import {
import { useState } from "react";
import { toast } from "react-toastify";
import { z } from "zod";
import { startOAuthFlow } from "../auth";
import { startOAuthFlow, refreshAccessToken } from "../auth";
import { SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
@@ -121,7 +121,49 @@ export function useConnection({
}
};
const connect = async () => {
const initiateOAuthFlow = async () => {
sessionStorage.removeItem(SESSION_KEYS.ACCESS_TOKEN);
sessionStorage.removeItem(SESSION_KEYS.REFRESH_TOKEN);
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
const redirectUrl = await startOAuthFlow(sseUrl);
window.location.href = redirectUrl;
};
const handleTokenRefresh = async () => {
try {
const tokens = await refreshAccessToken(sseUrl);
sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token);
if (tokens.refresh_token) {
sessionStorage.setItem(
SESSION_KEYS.REFRESH_TOKEN,
tokens.refresh_token,
);
}
return tokens.access_token;
} catch (error) {
console.error("Token refresh failed:", error);
await initiateOAuthFlow();
throw error;
}
};
const handleAuthError = async (error: unknown) => {
if (error instanceof SseError && error.code === 401) {
if (sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN)) {
try {
await handleTokenRefresh();
return true;
} catch (error) {
console.error("Token refresh failed:", error);
}
} else {
await initiateOAuthFlow();
}
}
return false;
};
const connect = async (_e?: unknown, retryCount: number = 0) => {
try {
const client = new Client<Request, Notification, Result>(
{
@@ -182,14 +224,15 @@ export function useConnection({
await client.connect(clientTransport);
} catch (error) {
console.error("Failed to connect to MCP server:", error);
if (error instanceof SseError && error.code === 401) {
// Store the server URL for the callback handler
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
const redirectUrl = await startOAuthFlow(sseUrl);
window.location.href = redirectUrl;
return;
const shouldRetry = await handleAuthError(error);
if (shouldRetry) {
return connect(undefined, retryCount + 1);
}
if (error instanceof SseError && error.code === 401) {
// Don't set error state if we're about to redirect for auth
return;
}
throw error;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.4.0",
"version": "0.4.1",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -33,8 +33,8 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-client": "0.3.0",
"@modelcontextprotocol/inspector-server": "0.3.0",
"@modelcontextprotocol/inspector-client": "0.4.1",
"@modelcontextprotocol/inspector-server": "0.4.1",
"concurrently": "^9.0.1",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-server",
"version": "0.4.0",
"version": "0.4.1",
"description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View File

@@ -181,4 +181,16 @@ app.get("/config", (req, res) => {
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {});
try {
const server = app.listen(PORT);
server.on("listening", () => {
const addr = server.address();
const port = typeof addr === "string" ? addr : addr?.port;
console.log(`Proxy server listening on port ${port}`);
});
} catch (error) {
console.error("Failed to start server:", error);
process.exit(1);
}