Merge branch 'main' into alejandro.borbolla/copy-input
This commit is contained in:
commit
6505468fbe
6 changed files with 4402 additions and 1124 deletions
155
client/src/lib/__tests__/auth.test.ts
Normal file
155
client/src/lib/__tests__/auth.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { discoverScopes } from "../auth";
|
||||
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
discoverAuthorizationServerMetadata: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDiscoverAuth =
|
||||
discoverAuthorizationServerMetadata as jest.MockedFunction<
|
||||
typeof discoverAuthorizationServerMetadata
|
||||
>;
|
||||
|
||||
const baseMetadata = {
|
||||
issuer: "https://test.com",
|
||||
authorization_endpoint: "https://test.com/authorize",
|
||||
token_endpoint: "https://test.com/token",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
};
|
||||
|
||||
describe("discoverScopes", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: "returns joined scopes from OAuth metadata",
|
||||
mockResolves: baseMetadata,
|
||||
serverUrl: "https://example.com",
|
||||
expected: "read write",
|
||||
expectedCallUrl: "https://example.com/",
|
||||
},
|
||||
{
|
||||
name: "prefers resource metadata over OAuth metadata",
|
||||
mockResolves: baseMetadata,
|
||||
serverUrl: "https://example.com",
|
||||
resourceMetadata: {
|
||||
resource: "https://example.com",
|
||||
scopes_supported: ["admin", "full"],
|
||||
},
|
||||
expected: "admin full",
|
||||
},
|
||||
{
|
||||
name: "falls back to OAuth when resource has empty scopes",
|
||||
mockResolves: baseMetadata,
|
||||
serverUrl: "https://example.com",
|
||||
resourceMetadata: {
|
||||
resource: "https://example.com",
|
||||
scopes_supported: [],
|
||||
},
|
||||
expected: "read write",
|
||||
},
|
||||
{
|
||||
name: "normalizes URL with port and path",
|
||||
mockResolves: baseMetadata,
|
||||
serverUrl: "https://example.com:8080/some/path",
|
||||
expected: "read write",
|
||||
expectedCallUrl: "https://example.com:8080/",
|
||||
},
|
||||
{
|
||||
name: "normalizes URL with trailing slash",
|
||||
mockResolves: baseMetadata,
|
||||
serverUrl: "https://example.com/",
|
||||
expected: "read write",
|
||||
expectedCallUrl: "https://example.com/",
|
||||
},
|
||||
{
|
||||
name: "handles single scope",
|
||||
mockResolves: { ...baseMetadata, scopes_supported: ["admin"] },
|
||||
serverUrl: "https://example.com",
|
||||
expected: "admin",
|
||||
},
|
||||
{
|
||||
name: "prefers resource metadata even with fewer scopes",
|
||||
mockResolves: {
|
||||
...baseMetadata,
|
||||
scopes_supported: ["read", "write", "admin", "full"],
|
||||
},
|
||||
serverUrl: "https://example.com",
|
||||
resourceMetadata: {
|
||||
resource: "https://example.com",
|
||||
scopes_supported: ["read"],
|
||||
},
|
||||
expected: "read",
|
||||
},
|
||||
];
|
||||
|
||||
const undefinedCases = [
|
||||
{
|
||||
name: "returns undefined when OAuth discovery fails",
|
||||
mockRejects: new Error("Discovery failed"),
|
||||
serverUrl: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "returns undefined when OAuth has no scopes",
|
||||
mockResolves: { ...baseMetadata, scopes_supported: [] },
|
||||
serverUrl: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "returns undefined when scopes_supported missing",
|
||||
mockResolves: (() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { scopes_supported, ...rest } = baseMetadata;
|
||||
return rest;
|
||||
})(),
|
||||
serverUrl: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "returns undefined with resource metadata but OAuth fails",
|
||||
mockRejects: new Error("No OAuth metadata"),
|
||||
serverUrl: "https://example.com",
|
||||
resourceMetadata: {
|
||||
resource: "https://example.com",
|
||||
scopes_supported: ["read", "write"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test.each(testCases)(
|
||||
"$name",
|
||||
async ({
|
||||
mockResolves,
|
||||
serverUrl,
|
||||
resourceMetadata,
|
||||
expected,
|
||||
expectedCallUrl,
|
||||
}) => {
|
||||
mockDiscoverAuth.mockResolvedValue(mockResolves);
|
||||
|
||||
const result = await discoverScopes(serverUrl, resourceMetadata);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
if (expectedCallUrl) {
|
||||
expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(undefinedCases)(
|
||||
"$name",
|
||||
async ({ mockResolves, mockRejects, serverUrl, resourceMetadata }) => {
|
||||
if (mockRejects) {
|
||||
mockDiscoverAuth.mockRejectedValue(mockRejects);
|
||||
} else {
|
||||
mockDiscoverAuth.mockResolvedValue(mockResolves);
|
||||
}
|
||||
|
||||
const result = await discoverScopes(serverUrl, resourceMetadata);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -6,8 +6,45 @@ import {
|
|||
OAuthTokensSchema,
|
||||
OAuthClientMetadata,
|
||||
OAuthMetadata,
|
||||
OAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
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
|
||||
* @param serverUrl - The MCP server URL
|
||||
* @param resourceMetadata - Optional resource metadata containing preferred scopes
|
||||
* @returns Promise resolving to space-separated scope string or undefined
|
||||
*/
|
||||
export const discoverScopes = async (
|
||||
serverUrl: string,
|
||||
resourceMetadata?: OAuthProtectedResourceMetadata,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const metadata = await discoverAuthorizationServerMetadata(
|
||||
new URL("/", serverUrl),
|
||||
);
|
||||
|
||||
// Prefer resource metadata scopes, but fall back to OAuth metadata if empty
|
||||
const resourceScopes = resourceMetadata?.scopes_supported;
|
||||
const oauthScopes = metadata?.scopes_supported;
|
||||
|
||||
const scopesSupported =
|
||||
resourceScopes && resourceScopes.length > 0
|
||||
? resourceScopes
|
||||
: oauthScopes;
|
||||
|
||||
return scopesSupported && scopesSupported.length > 0
|
||||
? scopesSupported.join(" ")
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
console.debug("OAuth scope discovery failed:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getClientInformationFromSessionStorage = async ({
|
||||
serverUrl,
|
||||
|
|
@ -66,10 +103,15 @@ export const clearClientInformationFromSessionStorage = ({
|
|||
};
|
||||
|
||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||
constructor(protected serverUrl: string) {
|
||||
constructor(
|
||||
protected serverUrl: string,
|
||||
scope?: string,
|
||||
) {
|
||||
this.scope = scope;
|
||||
// Save the server URL to session storage
|
||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||
}
|
||||
scope: string | undefined;
|
||||
|
||||
get redirectUrl() {
|
||||
return window.location.origin + "/oauth/callback";
|
||||
|
|
@ -83,9 +125,14 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
response_types: ["code"],
|
||||
client_name: "MCP Inspector",
|
||||
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
||||
scope: this.scope ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
state(): string | Promise<string> {
|
||||
return generateOAuthState();
|
||||
}
|
||||
|
||||
async clientInformation() {
|
||||
// Try to get the preregistered client information from session storage first
|
||||
const preregisteredClientInformation =
|
||||
|
|
@ -105,10 +152,17 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||
// Remove client_secret before storing (not needed after initial OAuth flow)
|
||||
const safeInfo = Object.fromEntries(
|
||||
Object.entries(clientInformation).filter(
|
||||
([key]) => key !== "client_secret",
|
||||
),
|
||||
) as OAuthClientInformation;
|
||||
|
||||
// Save the dynamically registered client information to session storage
|
||||
saveClientInformationToSessionStorage({
|
||||
serverUrl: this.serverUrl,
|
||||
clientInformation,
|
||||
clientInformation: safeInfo,
|
||||
isPreregistered: false,
|
||||
});
|
||||
}
|
||||
|
|
@ -129,6 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|||
}
|
||||
|
||||
redirectToAuthorization(authorizationUrl: URL) {
|
||||
// Validate the URL using the shared utility
|
||||
validateRedirectUrl(authorizationUrl.href);
|
||||
window.location.href = authorizationUrl.href;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ import { useConnection } from "../useConnection";
|
|||
import { z } from "zod";
|
||||
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
||||
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
SSEClientTransportOptions,
|
||||
SseError,
|
||||
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import {
|
||||
ElicitResult,
|
||||
ElicitRequest,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { discoverScopes } from "../../auth";
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
|
|
@ -53,14 +58,27 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|||
Client: jest.fn().mockImplementation(() => mockClient),
|
||||
}));
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
||||
SSEClientTransport: jest.fn((url, options) => {
|
||||
mockSSETransport.url = url;
|
||||
mockSSETransport.options = options;
|
||||
return mockSSETransport;
|
||||
}),
|
||||
SseError: jest.fn(),
|
||||
}));
|
||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => {
|
||||
// Minimal mock class that supports instanceof checks
|
||||
class SseError extends Error {
|
||||
code: number;
|
||||
event: ErrorEvent;
|
||||
constructor(code: number, message: string, event: ErrorEvent) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.event = event;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
SSEClientTransport: jest.fn((url, options) => {
|
||||
mockSSETransport.url = url;
|
||||
mockSSETransport.options = options;
|
||||
return mockSSETransport;
|
||||
}),
|
||||
SseError,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: jest.fn((url, options) => {
|
||||
|
|
@ -85,11 +103,18 @@ jest.mock("@/lib/hooks/useToast", () => ({
|
|||
jest.mock("../../auth", () => ({
|
||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
||||
redirectUrl: "http://localhost:3000/oauth/callback",
|
||||
})),
|
||||
clearClientInformationFromSessionStorage: jest.fn(),
|
||||
saveClientInformationToSessionStorage: jest.fn(),
|
||||
discoverScopes: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockAuth = auth as jest.MockedFunction<typeof auth>;
|
||||
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
|
||||
typeof discoverScopes
|
||||
>;
|
||||
|
||||
describe("useConnection", () => {
|
||||
const defaultProps = {
|
||||
transportType: "sse" as const,
|
||||
|
|
@ -714,4 +739,267 @@ describe("useConnection", () => {
|
|||
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Error Handling with Scope Discovery", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuth.mockResolvedValue("AUTHORIZED");
|
||||
mockDiscoverScopes.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const setup401Error = () => {
|
||||
const mockErrorEvent = new ErrorEvent("error", {
|
||||
message: "Mock error event",
|
||||
});
|
||||
mockClient.connect.mockRejectedValueOnce(
|
||||
new SseError(401, "Unauthorized", mockErrorEvent),
|
||||
);
|
||||
};
|
||||
|
||||
const attemptConnection = async (props = defaultProps) => {
|
||||
const { result } = renderHook(() => useConnection(props));
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.connect();
|
||||
} catch {
|
||||
// Expected error from auth handling
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const testCases = [
|
||||
[
|
||||
"discovers and includes scopes in auth call",
|
||||
{
|
||||
discoveredScope: "read write admin",
|
||||
oauthScope: undefined,
|
||||
expectScopeCall: true,
|
||||
expectedAuthScope: "read write admin",
|
||||
authResult: "AUTHORIZED",
|
||||
},
|
||||
],
|
||||
[
|
||||
"handles scope discovery failure gracefully",
|
||||
{
|
||||
discoveredScope: undefined,
|
||||
oauthScope: undefined,
|
||||
expectScopeCall: true,
|
||||
expectedAuthScope: undefined,
|
||||
authResult: "AUTHORIZED",
|
||||
},
|
||||
],
|
||||
[
|
||||
"uses manual oauthScope override instead of discovered scopes",
|
||||
{
|
||||
discoveredScope: "discovered:scope",
|
||||
oauthScope: "manual:scope",
|
||||
expectScopeCall: false,
|
||||
expectedAuthScope: "manual:scope",
|
||||
authResult: "AUTHORIZED",
|
||||
},
|
||||
],
|
||||
[
|
||||
"triggers scope discovery when oauthScope is whitespace",
|
||||
{
|
||||
discoveredScope: "discovered:scope",
|
||||
oauthScope: " ",
|
||||
expectScopeCall: true,
|
||||
expectedAuthScope: "discovered:scope",
|
||||
authResult: "AUTHORIZED",
|
||||
},
|
||||
],
|
||||
[
|
||||
"handles auth failure after scope discovery",
|
||||
{
|
||||
discoveredScope: "read write",
|
||||
oauthScope: undefined,
|
||||
expectScopeCall: true,
|
||||
expectedAuthScope: "read write",
|
||||
authResult: "UNAUTHORIZED",
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
|
||||
test.each(testCases)(
|
||||
"should %s",
|
||||
async (
|
||||
_,
|
||||
{
|
||||
discoveredScope,
|
||||
oauthScope,
|
||||
expectScopeCall,
|
||||
expectedAuthScope,
|
||||
authResult = "AUTHORIZED",
|
||||
},
|
||||
) => {
|
||||
mockDiscoverScopes.mockResolvedValue(discoveredScope);
|
||||
mockAuth.mockResolvedValue(authResult as never);
|
||||
setup401Error();
|
||||
|
||||
const props =
|
||||
oauthScope !== undefined
|
||||
? { ...defaultProps, oauthScope }
|
||||
: defaultProps;
|
||||
await attemptConnection(props);
|
||||
|
||||
if (expectScopeCall) {
|
||||
expect(mockDiscoverScopes).toHaveBeenCalledWith(
|
||||
defaultProps.sseUrl,
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
expect(mockDiscoverScopes).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
|
||||
serverUrl: defaultProps.sseUrl,
|
||||
scope: expectedAuthScope,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("should handle slow scope discovery gracefully", async () => {
|
||||
mockDiscoverScopes.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(undefined), 100)),
|
||||
);
|
||||
|
||||
setup401Error();
|
||||
await attemptConnection();
|
||||
|
||||
expect(mockDiscoverScopes).toHaveBeenCalledWith(
|
||||
defaultProps.sseUrl,
|
||||
undefined,
|
||||
);
|
||||
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
|
||||
serverUrl: defaultProps.sseUrl,
|
||||
scope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP_PROXY_FULL_ADDRESS Configuration", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset the mock transport objects
|
||||
mockSSETransport.url = undefined;
|
||||
mockSSETransport.options = undefined;
|
||||
mockStreamableHTTPTransport.url = undefined;
|
||||
mockStreamableHTTPTransport.options = undefined;
|
||||
});
|
||||
|
||||
test("sends proxyFullAddress query parameter for stdio transport when configured", async () => {
|
||||
const propsWithProxyFullAddress = {
|
||||
...defaultProps,
|
||||
transportType: "stdio" as const,
|
||||
command: "test-command",
|
||||
args: "test-args",
|
||||
env: {},
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "https://example.com/inspector/mcp_proxy",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithProxyFullAddress),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the URL contains the proxyFullAddress parameter
|
||||
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
|
||||
"https://example.com/inspector/mcp_proxy",
|
||||
);
|
||||
});
|
||||
|
||||
test("sends proxyFullAddress query parameter for sse transport when configured", async () => {
|
||||
const propsWithProxyFullAddress = {
|
||||
...defaultProps,
|
||||
transportType: "sse" as const,
|
||||
sseUrl: "http://localhost:8080",
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "https://example.com/inspector/mcp_proxy",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithProxyFullAddress),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the URL contains the proxyFullAddress parameter
|
||||
expect(mockSSETransport.url?.searchParams.get("proxyFullAddress")).toBe(
|
||||
"https://example.com/inspector/mcp_proxy",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not send proxyFullAddress parameter when MCP_PROXY_FULL_ADDRESS is empty", async () => {
|
||||
const propsWithEmptyProxy = {
|
||||
...defaultProps,
|
||||
transportType: "stdio" as const,
|
||||
command: "test-command",
|
||||
args: "test-args",
|
||||
env: {},
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConnection(propsWithEmptyProxy));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that the URL does not contain the proxyFullAddress parameter
|
||||
expect(
|
||||
mockSSETransport.url?.searchParams.get("proxyFullAddress"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("does not send proxyFullAddress parameter for streamable-http transport", async () => {
|
||||
const propsWithStreamableHttp = {
|
||||
...defaultProps,
|
||||
transportType: "streamable-http" as const,
|
||||
sseUrl: "http://localhost:8080",
|
||||
config: {
|
||||
...DEFAULT_INSPECTOR_CONFIG,
|
||||
MCP_PROXY_FULL_ADDRESS: {
|
||||
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_FULL_ADDRESS,
|
||||
value: "https://example.com/inspector/mcp_proxy",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnection(propsWithStreamableHttp),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.connect();
|
||||
});
|
||||
|
||||
// Check that streamable-http transport doesn't get proxyFullAddress parameter
|
||||
expect(
|
||||
mockStreamableHTTPTransport.url?.searchParams.get("proxyFullAddress"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
ToolListChangedNotificationSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
Progress,
|
||||
LoggingLevel,
|
||||
ElicitRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
|
|
@ -35,12 +36,16 @@ 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 { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { Notification } from "../notificationTypes";
|
||||
import {
|
||||
auth,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import {
|
||||
clearClientInformationFromSessionStorage,
|
||||
InspectorOAuthClientProvider,
|
||||
saveClientInformationToSessionStorage,
|
||||
discoverScopes,
|
||||
} from "../auth";
|
||||
import packageJson from "../../../package.json";
|
||||
import {
|
||||
|
|
@ -72,6 +77,7 @@ interface UseConnectionOptions {
|
|||
onElicitationRequest?: (request: any, resolve: any) => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getRoots?: () => any[];
|
||||
defaultLoggingLevel?: LoggingLevel;
|
||||
}
|
||||
|
||||
export function useConnection({
|
||||
|
|
@ -86,10 +92,10 @@ export function useConnection({
|
|||
oauthScope,
|
||||
config,
|
||||
onNotification,
|
||||
onStdErrNotification,
|
||||
onPendingRequest,
|
||||
onElicitationRequest,
|
||||
getRoots,
|
||||
defaultLoggingLevel,
|
||||
}: UseConnectionOptions) {
|
||||
const [connectionStatus, setConnectionStatus] =
|
||||
useState<ConnectionStatus>("disconnected");
|
||||
|
|
@ -312,11 +318,27 @@ export function useConnection({
|
|||
|
||||
const handleAuthError = async (error: unknown) => {
|
||||
if (is401Error(error)) {
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||
let scope = oauthScope?.trim();
|
||||
if (!scope) {
|
||||
// Only discover resource metadata when we need to discover scopes
|
||||
let resourceMetadata;
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||
new URL("/", sseUrl),
|
||||
);
|
||||
} catch {
|
||||
// Resource metadata is optional, continue without it
|
||||
}
|
||||
scope = await discoverScopes(sseUrl, resourceMetadata);
|
||||
}
|
||||
const serverAuthProvider = new InspectorOAuthClientProvider(
|
||||
sseUrl,
|
||||
scope,
|
||||
);
|
||||
|
||||
const result = await auth(serverAuthProvider, {
|
||||
serverUrl: sseUrl,
|
||||
scope: oauthScope,
|
||||
scope,
|
||||
});
|
||||
return result === "AUTHORIZED";
|
||||
}
|
||||
|
|
@ -348,6 +370,7 @@ export function useConnection({
|
|||
return;
|
||||
}
|
||||
|
||||
let lastRequest = "";
|
||||
try {
|
||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||
// proxying through the inspector server first.
|
||||
|
|
@ -386,11 +409,20 @@ export function useConnection({
|
|||
|
||||
let mcpProxyServerUrl;
|
||||
switch (transportType) {
|
||||
case "stdio":
|
||||
case "stdio": {
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
|
||||
mcpProxyServerUrl.searchParams.append("command", command);
|
||||
mcpProxyServerUrl.searchParams.append("args", args);
|
||||
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
|
||||
|
||||
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS
|
||||
.value as string;
|
||||
if (proxyFullAddress) {
|
||||
mcpProxyServerUrl.searchParams.append(
|
||||
"proxyFullAddress",
|
||||
proxyFullAddress,
|
||||
);
|
||||
}
|
||||
transportOptions = {
|
||||
authProvider: serverAuthProvider,
|
||||
eventSourceInit: {
|
||||
|
|
@ -408,10 +440,20 @@ export function useConnection({
|
|||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "sse":
|
||||
case "sse": {
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
|
||||
mcpProxyServerUrl.searchParams.append("url", sseUrl);
|
||||
|
||||
const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS
|
||||
.value as string;
|
||||
if (proxyFullAddressSSE) {
|
||||
mcpProxyServerUrl.searchParams.append(
|
||||
"proxyFullAddress",
|
||||
proxyFullAddressSSE,
|
||||
);
|
||||
}
|
||||
transportOptions = {
|
||||
eventSourceInit: {
|
||||
fetch: (
|
||||
|
|
@ -428,6 +470,7 @@ export function useConnection({
|
|||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "streamable-http":
|
||||
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
|
||||
|
|
@ -481,13 +524,6 @@ export function useConnection({
|
|||
};
|
||||
}
|
||||
|
||||
if (onStdErrNotification) {
|
||||
client.setNotificationHandler(
|
||||
StdErrNotificationSchema,
|
||||
onStdErrNotification,
|
||||
);
|
||||
}
|
||||
|
||||
let capabilities;
|
||||
try {
|
||||
const transport =
|
||||
|
|
@ -560,6 +596,12 @@ export function useConnection({
|
|||
});
|
||||
}
|
||||
|
||||
if (capabilities?.logging && defaultLoggingLevel) {
|
||||
lastRequest = "logging/setLevel";
|
||||
await client.setLoggingLevel(defaultLoggingLevel);
|
||||
lastRequest = "";
|
||||
}
|
||||
|
||||
if (onElicitationRequest) {
|
||||
client.setRequestHandler(ElicitRequestSchema, async (request) => {
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -571,6 +613,17 @@ export function useConnection({
|
|||
setMcpClient(client);
|
||||
setConnectionStatus("connected");
|
||||
} catch (e) {
|
||||
if (
|
||||
lastRequest === "logging/setLevel" &&
|
||||
e instanceof McpError &&
|
||||
e.code === ErrorCode.MethodNotFound
|
||||
) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
console.error(e);
|
||||
setConnectionStatus("error");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { OAuthStep, AuthDebuggerState } from "./auth-types";
|
||||
import { DebugInspectorOAuthClientProvider } from "./auth";
|
||||
import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth";
|
||||
import {
|
||||
discoverOAuthMetadata,
|
||||
discoverAuthorizationServerMetadata,
|
||||
registerClient,
|
||||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
OAuthMetadataSchema,
|
||||
OAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import { generateOAuthState } from "@/utils/oauthUtils";
|
||||
|
||||
export interface StateMachineContext {
|
||||
state: AuthDebuggerState;
|
||||
|
|
@ -56,7 +57,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
resourceMetadata ?? undefined,
|
||||
);
|
||||
|
||||
const metadata = await discoverOAuthMetadata(authServerUrl);
|
||||
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
|
||||
if (!metadata) {
|
||||
throw new Error("Failed to discover OAuth metadata");
|
||||
}
|
||||
|
|
@ -88,12 +89,16 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
clientMetadata.scope = scopesSupported.join(" ");
|
||||
}
|
||||
|
||||
const fullInformation = await registerClient(context.serverUrl, {
|
||||
metadata,
|
||||
clientMetadata,
|
||||
});
|
||||
// Try Static client first, with DCR as fallback
|
||||
let fullInformation = await context.provider.clientInformation();
|
||||
if (!fullInformation) {
|
||||
fullInformation = await registerClient(context.serverUrl, {
|
||||
metadata,
|
||||
clientMetadata,
|
||||
});
|
||||
context.provider.saveClientInformation(fullInformation);
|
||||
}
|
||||
|
||||
context.provider.saveClientInformation(fullInformation);
|
||||
context.updateState({
|
||||
oauthClientInfo: fullInformation,
|
||||
oauthStep: "authorization_redirect",
|
||||
|
|
@ -108,10 +113,10 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
const metadata = context.state.oauthMetadata!;
|
||||
const clientInformation = context.state.oauthClientInfo!;
|
||||
|
||||
let scope: string | undefined = undefined;
|
||||
if (metadata.scopes_supported) {
|
||||
scope = metadata.scopes_supported.join(" ");
|
||||
}
|
||||
const scope = await discoverScopes(
|
||||
context.serverUrl,
|
||||
context.state.resourceMetadata ?? undefined,
|
||||
);
|
||||
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||
context.serverUrl,
|
||||
|
|
@ -120,6 +125,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
clientInformation,
|
||||
redirectUrl: context.provider.redirectUrl,
|
||||
scope,
|
||||
state: generateOAuthState(),
|
||||
resource: context.state.resource ?? undefined,
|
||||
},
|
||||
);
|
||||
|
|
@ -171,7 +177,11 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
|||
authorizationCode: context.state.authorizationCode,
|
||||
codeVerifier,
|
||||
redirectUri: context.provider.redirectUrl,
|
||||
resource: context.state.resource ?? undefined,
|
||||
resource: context.state.resource
|
||||
? context.state.resource instanceof URL
|
||||
? context.state.resource
|
||||
: new URL(context.state.resource)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
context.provider.saveTokens(tokens);
|
||||
|
|
|
|||
Loading…
Reference in a new issue