Merge branch 'main' into update-dependencies

This commit is contained in:
Ola Hungerford 2025-08-27 20:13:37 -07:00 • committed by GitHub
commit 07a43c9266
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 840 additions and 625 deletions

View file

@ -18,10 +18,6 @@ jobs:
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
) &&
(
github.actor == 'ihrpr' ||
github.actor == 'olaservo'
)
runs-on: ubuntu-latest
permissions:
@ -29,6 +25,7 @@ jobs:
pull-requests: read
issues: read
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
@ -41,24 +38,16 @@ jobs:
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Allow Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Trigger when assigned to an issue
assignee_trigger: "claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Allow Claude to run bash
# This should be safe given the repo is already public
allowed_tools: "Bash"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
custom_instructions: |
If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block.

View file

@ -1,5 +1,5 @@
# Build stage
FROM node:24-slim AS builder
FROM node:current-alpine3.22 AS builder
# Set working directory
WORKDIR /app
@ -49,4 +49,4 @@ ENV SERVER_PORT=6277
EXPOSE ${CLIENT_PORT} ${SERVER_PORT}
# Use ENTRYPOINT with CMD for arguments
ENTRYPOINT ["npm", "start"]
ENTRYPOINT ["npm", "start"]

View file

@ -29,6 +29,14 @@ npx @modelcontextprotocol/inspector
The server will start up and the UI will be accessible at `http://localhost:6274`.
### Docker Container
You can also start it in a Docker container with the following command:
```bash
docker run --rm --network host -p 6274:6274 -p 6277:6277 ghcr.io/modelcontextprotocol/inspector:latest
```
### From an MCP server repository
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`:

View file

@ -34,7 +34,6 @@ import {
useDraggablePane,
useDraggableSidebar,
} from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
@ -106,9 +105,6 @@ const App = () => {
>(getInitialTransportType);
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[]
>([]);
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
@ -224,12 +220,6 @@ const App = () => {
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
onStdErrNotification: (notification) => {
setStdErrNotifications((prev) => [
...prev,
notification as StdErrNotification,
]);
},
onPendingRequest: (request, resolve, reject) => {
setPendingSampleRequests((prev) => [
...prev,
@ -757,10 +747,6 @@ const App = () => {
setLogLevel(level);
};
const clearStdErrNotifications = () => {
setStdErrNotifications([]);
};
const AuthDebuggerWrapper = () => (
<TabsContent value="auth">
<AuthDebugger
@ -829,11 +815,9 @@ const App = () => {
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/>
<div
onMouseDown={handleSidebarDragStart}

View file

@ -6,6 +6,7 @@ import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { SESSION_KEYS } from "../lib/constants";
import { validateRedirectUrl } from "@/utils/urlValidation";
export interface AuthDebuggerProps {
serverUrl: string;
@ -163,6 +164,23 @@ const AuthDebugger = ({
currentState.oauthStep === "authorization_code" &&
currentState.authorizationUrl
) {
// Validate the URL before redirecting
try {
validateRedirectUrl(currentState.authorizationUrl);
} catch (error) {
updateAuthState({
...currentState,
isInitiatingAuth: false,
latestError:
error instanceof Error ? error : new Error(String(error)),
statusMessage: {
type: "error",
message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`,
},
});
return;
}
// Store the current auth state before redirecting
sessionStorage.setItem(
SESSION_KEYS.AUTH_DEBUGGER_STATE,

View file

@ -4,6 +4,8 @@ import { Button } from "./ui/button";
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
import { useEffect, useMemo, useState } from "react";
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { validateRedirectUrl } from "@/utils/urlValidation";
import { useToast } from "@/lib/hooks/useToast";
interface OAuthStepProps {
label: string;
@ -71,6 +73,7 @@ export const OAuthFlowProgress = ({
updateAuthState,
proceedToNextStep,
}: OAuthFlowProgressProps) => {
const { toast } = useToast();
const provider = useMemo(
() => new DebugInspectorOAuthClientProvider(serverUrl),
[serverUrl],
@ -239,16 +242,32 @@ export const OAuthFlowProgress = ({
<p className="text-xs break-all">
{authState.authorizationUrl}
</p>
<a
href={authState.authorizationUrl}
target="_blank"
rel="noopener noreferrer"
<button
onClick={() => {
try {
validateRedirectUrl(authState.authorizationUrl!);
window.open(
authState.authorizationUrl!,
"_blank",
"noopener noreferrer",
);
} catch (error) {
toast({
title: "Invalid URL",
description:
error instanceof Error
? error.message
: "The authorization URL is not valid",
variant: "destructive",
});
}
}}
className="flex items-center text-blue-500 hover:text-blue-700"
aria-label="Open authorization URL in new tab"
title="Open authorization URL"
>
<ExternalLink className="h-4 w-4" />
</a>
</button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Click the link to authorize in your browser. After
@ -354,7 +373,21 @@ export const OAuthFlowProgress = ({
authState.authorizationUrl && (
<Button
variant="outline"
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
onClick={() => {
try {
validateRedirectUrl(authState.authorizationUrl!);
window.open(authState.authorizationUrl!, "_blank");
} catch (error) {
toast({
title: "Invalid URL",
description:
error instanceof Error
? error.message
: "The authorization URL is not valid",
variant: "destructive",
});
}
}}
>
Open in New Tab
</Button>

View file

@ -24,7 +24,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
@ -62,8 +61,6 @@ interface SidebarProps {
setOauthScope: (scope: string) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
clearStdErrNotifications: () => void;
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
@ -93,8 +90,6 @@ const Sidebar = ({
setOauthScope,
onConnect,
onDisconnect,
stdErrNotifications,
clearStdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
@ -268,6 +263,7 @@ const Sidebar = ({
placeholder="Command"
value={command}
onChange={(e) => setCommand(e.target.value)}
onBlur={(e) => setCommand(e.target.value.trim())}
className="font-mono"
/>
</div>
@ -760,36 +756,6 @@ const Sidebar = ({
</Select>
</div>
)}
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<Button
variant="outline"
size="sm"
onClick={clearStdErrNotifications}
className="h-8 px-2"
>
Clear
</Button>
</div>
<div className="mt-2 max-h-80 overflow-y-auto">
{stdErrNotifications.map((notification, index) => (
<div
key={index}
className="text-sm text-red-500 font-mono py-2 border-b border-gray-200 last:border-b-0"
>
{notification.params.content}
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import {
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
import { generateOAuthState } from "@/utils/oauthUtils";
import { validateRedirectUrl } from "@/utils/urlValidation";
/**
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
@ -182,12 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
redirectToAuthorization(authorizationUrl: URL) {
if (
authorizationUrl.protocol !== "http:" &&
authorizationUrl.protocol !== "https:"
) {
throw new Error("Authorization URL must be HTTP or HTTPS");
}
// Validate the URL using the shared utility
validateRedirectUrl(authorizationUrl.href);
window.location.href = authorizationUrl.href;
}

View file

@ -36,7 +36,7 @@ import { useEffect, useState } from "react";
import { useToast } from "@/lib/hooks/useToast";
import { z } from "zod";
import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { Notification } from "../notificationTypes";
import {
auth,
discoverOAuthProtectedResourceMetadata,
@ -92,7 +92,6 @@ export function useConnection({
oauthScope,
config,
onNotification,
onStdErrNotification,
onPendingRequest,
onElicitationRequest,
getRoots,
@ -505,13 +504,6 @@ export function useConnection({
};
}
if (onStdErrNotification) {
client.setNotificationHandler(
StdErrNotificationSchema,
onStdErrNotification,
);
}
let capabilities;
try {
const transport =

View file

@ -5,18 +5,8 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
export const StdErrNotificationSchema = BaseNotificationSchema.extend({
method: z.literal("notifications/stderr"),
params: z.object({
content: z.string(),
}),
});
export const NotificationSchema = ClientNotificationSchema.or(
StdErrNotificationSchema,
)
.or(ServerNotificationSchema)
.or(BaseNotificationSchema);
ServerNotificationSchema,
).or(BaseNotificationSchema);
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
export type Notification = z.infer<typeof NotificationSchema>;

View file

@ -0,0 +1,135 @@
import { validateRedirectUrl } from "../urlValidation";
describe("validateRedirectUrl", () => {
describe("valid URLs", () => {
it("should allow HTTP URLs", () => {
expect(() => validateRedirectUrl("http://example.com")).not.toThrow();
});
it("should allow HTTPS URLs", () => {
expect(() => validateRedirectUrl("https://example.com")).not.toThrow();
});
it("should allow URLs with ports", () => {
expect(() =>
validateRedirectUrl("https://example.com:8080"),
).not.toThrow();
});
it("should allow URLs with paths", () => {
expect(() =>
validateRedirectUrl("https://example.com/path/to/auth"),
).not.toThrow();
});
it("should allow URLs with query parameters", () => {
expect(() =>
validateRedirectUrl("https://example.com?param=value"),
).not.toThrow();
});
});
describe("invalid URLs - XSS vectors", () => {
it("should block javascript: protocol", () => {
expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow(
"Authorization URL must be HTTP or HTTPS",
);
});
it("should block javascript: with encoded characters", () => {
expect(() =>
validateRedirectUrl("javascript:alert%28%27XSS%27%29"),
).toThrow("Authorization URL must be HTTP or HTTPS");
});
it("should block data: protocol", () => {
expect(() =>
validateRedirectUrl("data:text/html,<script>alert('XSS')</script>"),
).toThrow("Authorization URL must be HTTP or HTTPS");
});
it("should block vbscript: protocol", () => {
expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow(
"Authorization URL must be HTTP or HTTPS",
);
});
it("should block file: protocol", () => {
expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow(
"Authorization URL must be HTTP or HTTPS",
);
});
it("should block about: protocol", () => {
expect(() => validateRedirectUrl("about:blank")).toThrow(
"Authorization URL must be HTTP or HTTPS",
);
});
it("should block custom protocols", () => {
expect(() => validateRedirectUrl("custom://example")).toThrow(
"Authorization URL must be HTTP or HTTPS",
);
});
});
describe("edge cases", () => {
it("should handle malformed URLs", () => {
expect(() => validateRedirectUrl("not a url")).toThrow(
"Invalid URL: not a url",
);
});
it("should handle empty string", () => {
expect(() => validateRedirectUrl("")).toThrow("Invalid URL: ");
});
it("should handle URLs with unicode characters", () => {
expect(() => validateRedirectUrl("https://例え.jp")).not.toThrow();
});
it("should handle URLs with case variations", () => {
expect(() => validateRedirectUrl("HTTPS://EXAMPLE.COM")).not.toThrow();
expect(() => validateRedirectUrl("HtTpS://example.com")).not.toThrow();
});
it("should handle protocol-relative URLs as invalid", () => {
expect(() => validateRedirectUrl("//example.com")).toThrow(
"Invalid URL: //example.com",
);
});
it("should handle URLs with authentication", () => {
expect(() =>
validateRedirectUrl("https://user:pass@example.com"),
).not.toThrow();
});
});
describe("security considerations", () => {
it("should not be fooled by whitespace", () => {
expect(() => validateRedirectUrl(" javascript:alert('XSS')")).toThrow();
expect(() => validateRedirectUrl("javascript:alert('XSS') ")).toThrow();
});
it("should handle null bytes", () => {
expect(() =>
validateRedirectUrl("java\x00script:alert('XSS')"),
).toThrow();
});
it("should handle tab characters", () => {
expect(() => validateRedirectUrl("java\tscript:alert('XSS')")).toThrow();
});
it("should handle newlines", () => {
expect(() => validateRedirectUrl("java\nscript:alert('XSS')")).toThrow();
});
it("should handle mixed case protocols", () => {
expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow(
"Authorization URL must be HTTP or HTTPS",
);
});
});
});

View file

@ -0,0 +1,24 @@
/**
* Validates that a URL is safe for redirection.
* Only allows HTTP and HTTPS protocols to prevent XSS attacks.
*
* @param url - The URL string to validate
* @throws Error if the URL has an unsafe protocol
*/
export function validateRedirectUrl(url: string): void {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new Error("Authorization URL must be HTTP or HTTPS");
}
} catch (error) {
if (
error instanceof Error &&
error.message === "Authorization URL must be HTTP or HTTPS"
) {
throw error;
}
// If URL parsing fails, it's also invalid
throw new Error(`Invalid URL: ${url}`);
}
}

View file

@ -181,7 +181,7 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
const transportType = query.transportType as string;
if (transportType === "stdio") {
const command = query.command as string;
const command = (query.command as string).trim();
const origArgs = shellParseArgs(query.args as string) as string[];
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
const env = { ...defaultEnvironment, ...process.env, ...queryEnv };
@ -401,24 +401,66 @@ app.get(
(serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => {
if (chunk.toString().includes("MODULE_NOT_FOUND")) {
// Server command not found, remove transports
const message = "Command not found, transports removed";
webAppTransport.send({
jsonrpc: "2.0",
method: "notifications/stderr",
method: "notifications/message",
params: {
content: "Command not found, transports removed",
level: "emergency",
logger: "proxy",
data: {
message,
},
},
});
webAppTransport.close();
serverTransport.close();
webAppTransports.delete(webAppTransport.sessionId);
serverTransports.delete(webAppTransport.sessionId);
console.error("Command not found, transports removed");
console.error(message);
} else {
// Inspect message and attempt to assign a RFC 5424 Syslog Protocol level
let level;
let message = chunk.toString().trim();
let ucMsg = chunk.toString().toUpperCase();
if (ucMsg.includes("DEBUG")) {
level = "debug";
} else if (ucMsg.includes("INFO")) {
level = "info";
} else if (ucMsg.includes("NOTICE")) {
level = "notice";
} else if (ucMsg.includes("WARN")) {
level = "warning";
} else if (ucMsg.includes("ERROR")) {
level = "error";
} else if (ucMsg.includes("CRITICAL")) {
level = "critical";
} else if (ucMsg.includes("ALERT")) {
level = "alert";
} else if (ucMsg.includes("EMERGENCY")) {
level = "emergency";
} else if (ucMsg.includes("SIGINT")) {
message = "SIGINT received. Server shutdown.";
level = "emergency";
} else if (ucMsg.includes("SIGHUP")) {
message = "SIGHUP received. Server shutdown.";
level = "emergency";
} else if (ucMsg.includes("SIGTERM")) {
message = "SIGTERM received. Server shutdown.";
level = "emergency";
} else {
level = "info";
}
webAppTransport.send({
jsonrpc: "2.0",
method: "notifications/stderr",
method: "notifications/message",
params: {
content: chunk.toString(),
level,
logger: "stdio",
data: {
message,
},
},
});
}