301 lines
8.8 KiB
TypeScript
301 lines
8.8 KiB
TypeScript
import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
import {
|
|
OAuthClientInformationSchema,
|
|
OAuthClientInformation,
|
|
OAuthTokens,
|
|
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,
|
|
isPreregistered,
|
|
}: {
|
|
serverUrl: string;
|
|
isPreregistered?: boolean;
|
|
}) => {
|
|
const key = getServerSpecificKey(
|
|
isPreregistered
|
|
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
|
|
: SESSION_KEYS.CLIENT_INFORMATION,
|
|
serverUrl,
|
|
);
|
|
|
|
const value = sessionStorage.getItem(key);
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
|
|
return await OAuthClientInformationSchema.parseAsync(JSON.parse(value));
|
|
};
|
|
|
|
export const saveClientInformationToSessionStorage = ({
|
|
serverUrl,
|
|
clientInformation,
|
|
isPreregistered,
|
|
}: {
|
|
serverUrl: string;
|
|
clientInformation: OAuthClientInformation;
|
|
isPreregistered?: boolean;
|
|
}) => {
|
|
const key = getServerSpecificKey(
|
|
isPreregistered
|
|
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
|
|
: SESSION_KEYS.CLIENT_INFORMATION,
|
|
serverUrl,
|
|
);
|
|
sessionStorage.setItem(key, JSON.stringify(clientInformation));
|
|
};
|
|
|
|
export const clearClientInformationFromSessionStorage = ({
|
|
serverUrl,
|
|
isPreregistered,
|
|
}: {
|
|
serverUrl: string;
|
|
isPreregistered?: boolean;
|
|
}) => {
|
|
const key = getServerSpecificKey(
|
|
isPreregistered
|
|
? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION
|
|
: SESSION_KEYS.CLIENT_INFORMATION,
|
|
serverUrl,
|
|
);
|
|
sessionStorage.removeItem(key);
|
|
};
|
|
|
|
export const getScopeFromSessionStorage = (
|
|
serverUrl: string,
|
|
): string | undefined => {
|
|
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
|
const value = sessionStorage.getItem(key);
|
|
return value || undefined;
|
|
};
|
|
|
|
export const saveScopeToSessionStorage = (
|
|
serverUrl: string,
|
|
scope: string | undefined,
|
|
) => {
|
|
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
|
if (scope) {
|
|
sessionStorage.setItem(key, scope);
|
|
} else {
|
|
sessionStorage.removeItem(key);
|
|
}
|
|
};
|
|
|
|
export const clearScopeFromSessionStorage = (serverUrl: string) => {
|
|
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
|
|
sessionStorage.removeItem(key);
|
|
};
|
|
|
|
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
|
constructor(protected serverUrl: string) {
|
|
// Save the server URL to session storage
|
|
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
|
}
|
|
|
|
get scope(): string | undefined {
|
|
return getScopeFromSessionStorage(this.serverUrl);
|
|
}
|
|
|
|
get redirectUrl() {
|
|
return window.location.origin + "/oauth/callback";
|
|
}
|
|
|
|
get debugRedirectUrl() {
|
|
return window.location.origin + "/oauth/callback/debug";
|
|
}
|
|
|
|
get redirect_uris() {
|
|
// Always register both redirect URIs to support both normal and debug flows.
|
|
// Use the base URLs directly (not this.redirectUrl) to avoid the debug subclass
|
|
// override collapsing both URIs into one, which causes the normal flow's
|
|
// /authorize request to use an unregistered redirect_uri.
|
|
// See: https://github.com/modelcontextprotocol/inspector/issues/825
|
|
// See: https://github.com/modelcontextprotocol/inspector/issues/930
|
|
const callbackUrl = window.location.origin + "/oauth/callback";
|
|
const debugCallbackUrl = window.location.origin + "/oauth/callback/debug";
|
|
return [callbackUrl, debugCallbackUrl];
|
|
}
|
|
|
|
get clientMetadata(): OAuthClientMetadata {
|
|
const metadata: OAuthClientMetadata = {
|
|
redirect_uris: this.redirect_uris,
|
|
token_endpoint_auth_method: "none",
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
response_types: ["code"],
|
|
client_name: "MCP Inspector",
|
|
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
|
};
|
|
|
|
// Only include scope if it's defined and non-empty
|
|
// Per OAuth spec, omit the scope field entirely if no scopes are requested
|
|
if (this.scope) {
|
|
metadata.scope = this.scope;
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
state(): string | Promise<string> {
|
|
return generateOAuthState();
|
|
}
|
|
|
|
async clientInformation() {
|
|
// Try to get the preregistered client information from session storage first
|
|
const preregisteredClientInformation =
|
|
await getClientInformationFromSessionStorage({
|
|
serverUrl: this.serverUrl,
|
|
isPreregistered: true,
|
|
});
|
|
|
|
// If no preregistered client information is found, get the dynamically registered client information
|
|
return (
|
|
preregisteredClientInformation ??
|
|
(await getClientInformationFromSessionStorage({
|
|
serverUrl: this.serverUrl,
|
|
isPreregistered: false,
|
|
}))
|
|
);
|
|
}
|
|
|
|
saveClientInformation(clientInformation: OAuthClientInformation) {
|
|
// Save the dynamically registered client information to session storage
|
|
saveClientInformationToSessionStorage({
|
|
serverUrl: this.serverUrl,
|
|
clientInformation,
|
|
isPreregistered: false,
|
|
});
|
|
}
|
|
|
|
async tokens() {
|
|
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
|
const tokens = sessionStorage.getItem(key);
|
|
if (!tokens) {
|
|
return undefined;
|
|
}
|
|
|
|
return await OAuthTokensSchema.parseAsync(JSON.parse(tokens));
|
|
}
|
|
|
|
saveTokens(tokens: OAuthTokens) {
|
|
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
|
|
sessionStorage.setItem(key, JSON.stringify(tokens));
|
|
}
|
|
|
|
redirectToAuthorization(authorizationUrl: URL) {
|
|
// Validate the URL using the shared utility
|
|
validateRedirectUrl(authorizationUrl.href);
|
|
window.location.href = authorizationUrl.href;
|
|
}
|
|
|
|
saveCodeVerifier(codeVerifier: string) {
|
|
const key = getServerSpecificKey(
|
|
SESSION_KEYS.CODE_VERIFIER,
|
|
this.serverUrl,
|
|
);
|
|
sessionStorage.setItem(key, codeVerifier);
|
|
}
|
|
|
|
codeVerifier() {
|
|
const key = getServerSpecificKey(
|
|
SESSION_KEYS.CODE_VERIFIER,
|
|
this.serverUrl,
|
|
);
|
|
const verifier = sessionStorage.getItem(key);
|
|
if (!verifier) {
|
|
throw new Error("No code verifier saved for session");
|
|
}
|
|
|
|
return verifier;
|
|
}
|
|
|
|
clear() {
|
|
clearClientInformationFromSessionStorage({
|
|
serverUrl: this.serverUrl,
|
|
isPreregistered: false,
|
|
});
|
|
sessionStorage.removeItem(
|
|
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
|
|
);
|
|
sessionStorage.removeItem(
|
|
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Overrides redirect URL to use the debug endpoint and allows saving server OAuth metadata to
|
|
// display in debug UI.
|
|
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
|
|
get redirectUrl(): string {
|
|
// We can use the debug redirect URL here because it was already registered
|
|
// in the parent class's clientMetadata along with the normal redirect URL
|
|
return this.debugRedirectUrl;
|
|
}
|
|
|
|
saveServerMetadata(metadata: OAuthMetadata) {
|
|
const key = getServerSpecificKey(
|
|
SESSION_KEYS.SERVER_METADATA,
|
|
this.serverUrl,
|
|
);
|
|
sessionStorage.setItem(key, JSON.stringify(metadata));
|
|
}
|
|
|
|
getServerMetadata(): OAuthMetadata | null {
|
|
const key = getServerSpecificKey(
|
|
SESSION_KEYS.SERVER_METADATA,
|
|
this.serverUrl,
|
|
);
|
|
const metadata = sessionStorage.getItem(key);
|
|
if (!metadata) {
|
|
return null;
|
|
}
|
|
return JSON.parse(metadata);
|
|
}
|
|
|
|
clear() {
|
|
super.clear();
|
|
sessionStorage.removeItem(
|
|
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
|
|
);
|
|
}
|
|
}
|