|
| 1 | +import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 2 | +import { |
| 3 | + OAuthClientInformationSchema, |
| 4 | + OAuthClientInformation, |
| 5 | + OAuthTokens, |
| 6 | + OAuthTokensSchema, |
| 7 | + OAuthClientMetadata, |
| 8 | + OAuthMetadata, |
| 9 | + OAuthProtectedResourceMetadata, |
| 10 | +} from "@modelcontextprotocol/sdk/shared/auth.js"; |
| 11 | +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 12 | +import { SESSION_KEYS, getServerSpecificKey } from "./constants"; |
| 13 | +import { generateOAuthState } from "@/utils/oauthUtils"; |
| 14 | +import { validateRedirectUrl } from "@/utils/urlValidation"; |
| 15 | + |
| 16 | +/** |
| 17 | + * Discovers OAuth scopes from server metadata, with preference for resource metadata scopes |
| 18 | + * @param serverUrl - The MCP server URL |
| 19 | + * @param resourceMetadata - Optional resource metadata containing preferred scopes |
| 20 | + * @returns Promise resolving to space-separated scope string or undefined |
| 21 | + */ |
| 22 | +export const discoverScopes = async ( |
| 23 | + serverUrl: string, |
| 24 | + resourceMetadata?: OAuthProtectedResourceMetadata, |
| 25 | +): Promise<string | undefined> => { |
| 26 | + try { |
| 27 | + const metadata = await discoverAuthorizationServerMetadata( |
| 28 | + new URL("/", serverUrl), |
| 29 | + ); |
| 30 | + |
| 31 | + // Prefer resource metadata scopes, but fall back to OAuth metadata if empty |
| 32 | + const resourceScopes = resourceMetadata?.scopes_supported; |
| 33 | + const oauthScopes = metadata?.scopes_supported; |
| 34 | + |
| 35 | + const scopesSupported = |
| 36 | + resourceScopes && resourceScopes.length > 0 |
| 37 | + ? resourceScopes |
| 38 | + : oauthScopes; |
| 39 | + |
| 40 | + return scopesSupported && scopesSupported.length > 0 |
| 41 | + ? scopesSupported.join(" ") |
| 42 | + : undefined; |
| 43 | + } catch (error) { |
| 44 | + console.debug("OAuth scope discovery failed:", error); |
| 45 | + return undefined; |
| 46 | + } |
| 47 | +}; |
| 48 | + |
| 49 | +export const getClientInformationFromSessionStorage = async ({ |
| 50 | + serverUrl, |
| 51 | + isPreregistered, |
| 52 | +}: { |
| 53 | + serverUrl: string; |
| 54 | + isPreregistered?: boolean; |
| 55 | +}) => { |
| 56 | + const key = getServerSpecificKey( |
| 57 | + isPreregistered |
| 58 | + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION |
| 59 | + : SESSION_KEYS.CLIENT_INFORMATION, |
| 60 | + serverUrl, |
| 61 | + ); |
| 62 | + |
| 63 | + const value = sessionStorage.getItem(key); |
| 64 | + if (!value) { |
| 65 | + return undefined; |
| 66 | + } |
| 67 | + |
| 68 | + return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); |
| 69 | +}; |
| 70 | + |
| 71 | +export const saveClientInformationToSessionStorage = ({ |
| 72 | + serverUrl, |
| 73 | + clientInformation, |
| 74 | + isPreregistered, |
| 75 | +}: { |
| 76 | + serverUrl: string; |
| 77 | + clientInformation: OAuthClientInformation; |
| 78 | + isPreregistered?: boolean; |
| 79 | +}) => { |
| 80 | + const key = getServerSpecificKey( |
| 81 | + isPreregistered |
| 82 | + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION |
| 83 | + : SESSION_KEYS.CLIENT_INFORMATION, |
| 84 | + serverUrl, |
| 85 | + ); |
| 86 | + sessionStorage.setItem(key, JSON.stringify(clientInformation)); |
| 87 | +}; |
| 88 | + |
| 89 | +export const clearClientInformationFromSessionStorage = ({ |
| 90 | + serverUrl, |
| 91 | + isPreregistered, |
| 92 | +}: { |
| 93 | + serverUrl: string; |
| 94 | + isPreregistered?: boolean; |
| 95 | +}) => { |
| 96 | + const key = getServerSpecificKey( |
| 97 | + isPreregistered |
| 98 | + ? SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION |
| 99 | + : SESSION_KEYS.CLIENT_INFORMATION, |
| 100 | + serverUrl, |
| 101 | + ); |
| 102 | + sessionStorage.removeItem(key); |
| 103 | +}; |
| 104 | + |
| 105 | +export const getScopeFromSessionStorage = ( |
| 106 | + serverUrl: string, |
| 107 | +): string | undefined => { |
| 108 | + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); |
| 109 | + const value = sessionStorage.getItem(key); |
| 110 | + return value || undefined; |
| 111 | +}; |
| 112 | + |
| 113 | +export const saveScopeToSessionStorage = ( |
| 114 | + serverUrl: string, |
| 115 | + scope: string | undefined, |
| 116 | +) => { |
| 117 | + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); |
| 118 | + if (scope) { |
| 119 | + sessionStorage.setItem(key, scope); |
| 120 | + } else { |
| 121 | + sessionStorage.removeItem(key); |
| 122 | + } |
| 123 | +}; |
| 124 | + |
| 125 | +export const clearScopeFromSessionStorage = (serverUrl: string) => { |
| 126 | + const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl); |
| 127 | + sessionStorage.removeItem(key); |
| 128 | +}; |
| 129 | + |
| 130 | +export class InspectorOAuthClientProvider implements OAuthClientProvider { |
| 131 | + constructor(protected serverUrl: string) { |
| 132 | + // Save the server URL to session storage |
| 133 | + sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); |
| 134 | + } |
| 135 | + |
| 136 | + get scope(): string | undefined { |
| 137 | + return getScopeFromSessionStorage(this.serverUrl); |
| 138 | + } |
| 139 | + |
| 140 | + get redirectUrl() { |
| 141 | + return window.location.origin + "/oauth/callback"; |
| 142 | + } |
| 143 | + |
| 144 | + get debugRedirectUrl() { |
| 145 | + return window.location.origin + "/oauth/callback/debug"; |
| 146 | + } |
| 147 | + |
| 148 | + get redirect_uris() { |
| 149 | + // Normally register both redirect URIs to support both normal and debug flows |
| 150 | + // In debug subclass, redirectUrl may be the same as debugRedirectUrl, so remove duplicates |
| 151 | + // See: https://github.com/modelcontextprotocol/inspector/issues/825 |
| 152 | + return [...new Set([this.redirectUrl, this.debugRedirectUrl])]; |
| 153 | + } |
| 154 | + |
| 155 | + get clientMetadata(): OAuthClientMetadata { |
| 156 | + const metadata: OAuthClientMetadata = { |
| 157 | + redirect_uris: this.redirect_uris, |
| 158 | + token_endpoint_auth_method: "none", |
| 159 | + grant_types: ["authorization_code", "refresh_token"], |
| 160 | + response_types: ["code"], |
| 161 | + client_name: "MCP Inspector", |
| 162 | + client_uri: "https://github.com/modelcontextprotocol/inspector", |
| 163 | + }; |
| 164 | + |
| 165 | + // Only include scope if it's defined and non-empty |
| 166 | + // Per OAuth spec, omit the scope field entirely if no scopes are requested |
| 167 | + if (this.scope) { |
| 168 | + metadata.scope = this.scope; |
| 169 | + } |
| 170 | + |
| 171 | + return metadata; |
| 172 | + } |
| 173 | + |
| 174 | + state(): string | Promise<string> { |
| 175 | + return generateOAuthState(); |
| 176 | + } |
| 177 | + |
| 178 | + async clientInformation() { |
| 179 | + // Try to get the preregistered client information from session storage first |
| 180 | + const preregisteredClientInformation = |
| 181 | + await getClientInformationFromSessionStorage({ |
| 182 | + serverUrl: this.serverUrl, |
| 183 | + isPreregistered: true, |
| 184 | + }); |
| 185 | + |
| 186 | + // If no preregistered client information is found, get the dynamically registered client information |
| 187 | + return ( |
| 188 | + preregisteredClientInformation ?? |
| 189 | + (await getClientInformationFromSessionStorage({ |
| 190 | + serverUrl: this.serverUrl, |
| 191 | + isPreregistered: false, |
| 192 | + })) |
| 193 | + ); |
| 194 | + } |
| 195 | + |
| 196 | + saveClientInformation(clientInformation: OAuthClientInformation) { |
| 197 | + // Save the dynamically registered client information to session storage |
| 198 | + saveClientInformationToSessionStorage({ |
| 199 | + serverUrl: this.serverUrl, |
| 200 | + clientInformation, |
| 201 | + isPreregistered: false, |
| 202 | + }); |
| 203 | + } |
| 204 | + |
| 205 | + async tokens() { |
| 206 | + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); |
| 207 | + const tokens = sessionStorage.getItem(key); |
| 208 | + if (!tokens) { |
| 209 | + return undefined; |
| 210 | + } |
| 211 | + |
| 212 | + return await OAuthTokensSchema.parseAsync(JSON.parse(tokens)); |
| 213 | + } |
| 214 | + |
| 215 | + saveTokens(tokens: OAuthTokens) { |
| 216 | + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); |
| 217 | + sessionStorage.setItem(key, JSON.stringify(tokens)); |
| 218 | + } |
| 219 | + |
| 220 | + redirectToAuthorization(authorizationUrl: URL) { |
| 221 | + // Validate the URL using the shared utility |
| 222 | + validateRedirectUrl(authorizationUrl.href); |
| 223 | + window.location.href = authorizationUrl.href; |
| 224 | + } |
| 225 | + |
| 226 | + saveCodeVerifier(codeVerifier: string) { |
| 227 | + const key = getServerSpecificKey( |
| 228 | + SESSION_KEYS.CODE_VERIFIER, |
| 229 | + this.serverUrl, |
| 230 | + ); |
| 231 | + sessionStorage.setItem(key, codeVerifier); |
| 232 | + } |
| 233 | + |
| 234 | + codeVerifier() { |
| 235 | + const key = getServerSpecificKey( |
| 236 | + SESSION_KEYS.CODE_VERIFIER, |
| 237 | + this.serverUrl, |
| 238 | + ); |
| 239 | + const verifier = sessionStorage.getItem(key); |
| 240 | + if (!verifier) { |
| 241 | + throw new Error("No code verifier saved for session"); |
| 242 | + } |
| 243 | + |
| 244 | + return verifier; |
| 245 | + } |
| 246 | + |
| 247 | + clear() { |
| 248 | + clearClientInformationFromSessionStorage({ |
| 249 | + serverUrl: this.serverUrl, |
| 250 | + isPreregistered: false, |
| 251 | + }); |
| 252 | + sessionStorage.removeItem( |
| 253 | + getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), |
| 254 | + ); |
| 255 | + sessionStorage.removeItem( |
| 256 | + getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl), |
| 257 | + ); |
| 258 | + } |
| 259 | +} |
| 260 | + |
| 261 | +// Overrides redirect URL to use the debug endpoint and allows saving server OAuth metadata to |
| 262 | +// display in debug UI. |
| 263 | +export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { |
| 264 | + get redirectUrl(): string { |
| 265 | + // We can use the debug redirect URL here because it was already registered |
| 266 | + // in the parent class's clientMetadata along with the normal redirect URL |
| 267 | + return this.debugRedirectUrl; |
| 268 | + } |
| 269 | + |
| 270 | + saveServerMetadata(metadata: OAuthMetadata) { |
| 271 | + const key = getServerSpecificKey( |
| 272 | + SESSION_KEYS.SERVER_METADATA, |
| 273 | + this.serverUrl, |
| 274 | + ); |
| 275 | + sessionStorage.setItem(key, JSON.stringify(metadata)); |
| 276 | + } |
| 277 | + |
| 278 | + getServerMetadata(): OAuthMetadata | null { |
| 279 | + const key = getServerSpecificKey( |
| 280 | + SESSION_KEYS.SERVER_METADATA, |
| 281 | + this.serverUrl, |
| 282 | + ); |
| 283 | + const metadata = sessionStorage.getItem(key); |
| 284 | + if (!metadata) { |
| 285 | + return null; |
| 286 | + } |
| 287 | + return JSON.parse(metadata); |
| 288 | + } |
| 289 | + |
| 290 | + clear() { |
| 291 | + super.clear(); |
| 292 | + sessionStorage.removeItem( |
| 293 | + getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl), |
| 294 | + ); |
| 295 | + } |
| 296 | +} |
0 commit comments