@@ -25,6 +25,7 @@ const mockOAuthMetadata = {
2525 token_endpoint : "https://oauth.example.com/token" ,
2626 response_types_supported : [ "code" ] ,
2727 grant_types_supported : [ "authorization_code" ] ,
28+ scopes_supported : [ "read" , "write" ] ,
2829} ;
2930
3031const mockOAuthClientInfo = {
@@ -56,6 +57,57 @@ import {
5657import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js" ;
5758import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types" ;
5859
60+ // Mock local auth module
61+ jest . mock ( "@/lib/auth" , ( ) => ( {
62+ DebugInspectorOAuthClientProvider : jest . fn ( ) . mockImplementation ( ( ) => ( {
63+ tokens : jest . fn ( ) . mockImplementation ( ( ) => Promise . resolve ( undefined ) ) ,
64+ clear : jest . fn ( ) . mockImplementation ( ( ) => {
65+ // Mock the real clear() behavior which removes items from sessionStorage
66+ sessionStorage . removeItem ( "[https://example.com/mcp] mcp_tokens" ) ;
67+ sessionStorage . removeItem ( "[https://example.com/mcp] mcp_client_info" ) ;
68+ sessionStorage . removeItem (
69+ "[https://example.com/mcp] mcp_server_metadata" ,
70+ ) ;
71+ } ) ,
72+ redirectUrl : "http://localhost:3000/oauth/callback/debug" ,
73+ clientMetadata : {
74+ redirect_uris : [ "http://localhost:3000/oauth/callback/debug" ] ,
75+ token_endpoint_auth_method : "none" ,
76+ grant_types : [ "authorization_code" , "refresh_token" ] ,
77+ response_types : [ "code" ] ,
78+ client_name : "MCP Inspector" ,
79+ } ,
80+ clientInformation : jest . fn ( ) . mockImplementation ( async ( ) => {
81+ const serverUrl = "https://example.com/mcp" ;
82+ const preregisteredKey = `[${ serverUrl } ] ${ SESSION_KEYS . PREREGISTERED_CLIENT_INFORMATION } ` ;
83+ const preregisteredData = sessionStorage . getItem ( preregisteredKey ) ;
84+ if ( preregisteredData ) {
85+ return JSON . parse ( preregisteredData ) ;
86+ }
87+ const dynamicKey = `[${ serverUrl } ] ${ SESSION_KEYS . CLIENT_INFORMATION } ` ;
88+ const dynamicData = sessionStorage . getItem ( dynamicKey ) ;
89+ if ( dynamicData ) {
90+ return JSON . parse ( dynamicData ) ;
91+ }
92+ return undefined ;
93+ } ) ,
94+ saveClientInformation : jest . fn ( ) . mockImplementation ( ( clientInfo ) => {
95+ const serverUrl = "https://example.com/mcp" ;
96+ const key = `[${ serverUrl } ] ${ SESSION_KEYS . CLIENT_INFORMATION } ` ;
97+ sessionStorage . setItem ( key , JSON . stringify ( clientInfo ) ) ;
98+ } ) ,
99+ saveTokens : jest . fn ( ) ,
100+ redirectToAuthorization : jest . fn ( ) ,
101+ saveCodeVerifier : jest . fn ( ) ,
102+ codeVerifier : jest . fn ( ) ,
103+ saveServerMetadata : jest . fn ( ) ,
104+ getServerMetadata : jest . fn ( ) ,
105+ } ) ) ,
106+ discoverScopes : jest . fn ( ) . mockResolvedValue ( "read write" as never ) ,
107+ } ) ) ;
108+
109+ import { discoverScopes } from "@/lib/auth" ;
110+
59111// Type the mocked functions properly
60112const mockDiscoverAuthorizationServerMetadata =
61113 discoverAuthorizationServerMetadata as jest . MockedFunction <
@@ -75,6 +127,9 @@ const mockDiscoverOAuthProtectedResourceMetadata =
75127 discoverOAuthProtectedResourceMetadata as jest . MockedFunction <
76128 typeof discoverOAuthProtectedResourceMetadata
77129 > ;
130+ const mockDiscoverScopes = discoverScopes as jest . MockedFunction <
131+ typeof discoverScopes
132+ > ;
78133
79134const sessionStorageMock = {
80135 getItem : jest . fn ( ) ,
@@ -103,9 +158,15 @@ describe("AuthDebugger", () => {
103158 // Suppress console errors in tests to avoid JSDOM navigation noise
104159 jest . spyOn ( console , "error" ) . mockImplementation ( ( ) => { } ) ;
105160
106- mockDiscoverAuthorizationServerMetadata . mockResolvedValue (
107- mockOAuthMetadata ,
108- ) ;
161+ // Set default mock behaviors with complete OAuth metadata
162+ mockDiscoverAuthorizationServerMetadata . mockResolvedValue ( {
163+ issuer : "https://oauth.example.com" ,
164+ authorization_endpoint : "https://oauth.example.com/authorize" ,
165+ token_endpoint : "https://oauth.example.com/token" ,
166+ response_types_supported : [ "code" ] ,
167+ grant_types_supported : [ "authorization_code" ] ,
168+ scopes_supported : [ "read" , "write" ] ,
169+ } ) ;
109170 mockRegisterClient . mockResolvedValue ( mockOAuthClientInfo ) ;
110171 mockDiscoverOAuthProtectedResourceMetadata . mockRejectedValue (
111172 new Error ( "No protected resource metadata found" ) ,
@@ -427,7 +488,24 @@ describe("AuthDebugger", () => {
427488 } ) ;
428489 } ) ;
429490
430- it ( "should not include scope in authorization URL when scopes_supported is not present" , async ( ) => {
491+ it ( "should include scope in authorization URL when scopes_supported is not present" , async ( ) => {
492+ const updateAuthState =
493+ await setupAuthorizationUrlTest ( mockOAuthMetadata ) ;
494+
495+ // Wait for the updateAuthState to be called
496+ await waitFor ( ( ) => {
497+ expect ( updateAuthState ) . toHaveBeenCalledWith (
498+ expect . objectContaining ( {
499+ authorizationUrl : expect . stringContaining ( "scope=" ) ,
500+ } ) ,
501+ ) ;
502+ } ) ;
503+ } ) ;
504+
505+ it ( "should omit scope from authorization URL when discoverScopes returns undefined" , async ( ) => {
506+ // Mock discoverScopes to return undefined (no scopes available)
507+ mockDiscoverScopes . mockResolvedValueOnce ( undefined ) ;
508+
431509 const updateAuthState =
432510 await setupAuthorizationUrlTest ( mockOAuthMetadata ) ;
433511
@@ -442,6 +520,103 @@ describe("AuthDebugger", () => {
442520 } ) ;
443521 } ) ;
444522
523+ describe ( "Client Registration behavior" , ( ) => {
524+ it ( "uses preregistered (static) client information without calling DCR" , async ( ) => {
525+ const preregClientInfo = {
526+ client_id : "static_client_id" ,
527+ client_secret : "static_client_secret" ,
528+ redirect_uris : [ "http://localhost:3000/oauth/callback/debug" ] ,
529+ } ;
530+
531+ // Return preregistered client info for the server-specific key
532+ sessionStorageMock . getItem . mockImplementation ( ( key ) => {
533+ if (
534+ key ===
535+ `[${ defaultProps . serverUrl } ] ${ SESSION_KEYS . PREREGISTERED_CLIENT_INFORMATION } `
536+ ) {
537+ return JSON . stringify ( preregClientInfo ) ;
538+ }
539+ return null ;
540+ } ) ;
541+
542+ const updateAuthState = jest . fn ( ) ;
543+
544+ await act ( async ( ) => {
545+ renderAuthDebugger ( {
546+ updateAuthState,
547+ authState : {
548+ ...defaultAuthState ,
549+ isInitiatingAuth : false ,
550+ oauthStep : "client_registration" ,
551+ oauthMetadata : mockOAuthMetadata as unknown as OAuthMetadata ,
552+ } ,
553+ } ) ;
554+ } ) ;
555+
556+ // Proceed from client_registration → authorization_redirect
557+ await act ( async ( ) => {
558+ fireEvent . click ( screen . getByText ( "Continue" ) ) ;
559+ } ) ;
560+
561+ // Should NOT attempt dynamic client registration
562+ expect ( mockRegisterClient ) . not . toHaveBeenCalled ( ) ;
563+
564+ // Should advance with the preregistered client info
565+ expect ( updateAuthState ) . toHaveBeenCalledWith (
566+ expect . objectContaining ( {
567+ oauthClientInfo : expect . objectContaining ( {
568+ client_id : "static_client_id" ,
569+ } ) ,
570+ oauthStep : "authorization_redirect" ,
571+ } ) ,
572+ ) ;
573+ } ) ;
574+
575+ it ( "falls back to DCR when no static client information is available" , async ( ) => {
576+ // No preregistered or dynamic client info present in session storage
577+ sessionStorageMock . getItem . mockImplementation ( ( ) => null ) ;
578+
579+ // DCR returns a new client
580+ mockRegisterClient . mockResolvedValueOnce ( mockOAuthClientInfo ) ;
581+
582+ const updateAuthState = jest . fn ( ) ;
583+
584+ await act ( async ( ) => {
585+ renderAuthDebugger ( {
586+ updateAuthState,
587+ authState : {
588+ ...defaultAuthState ,
589+ isInitiatingAuth : false ,
590+ oauthStep : "client_registration" ,
591+ oauthMetadata : mockOAuthMetadata as unknown as OAuthMetadata ,
592+ } ,
593+ } ) ;
594+ } ) ;
595+
596+ await act ( async ( ) => {
597+ fireEvent . click ( screen . getByText ( "Continue" ) ) ;
598+ } ) ;
599+
600+ expect ( mockRegisterClient ) . toHaveBeenCalledTimes ( 1 ) ;
601+
602+ // Should save and advance with the DCR client info
603+ expect ( updateAuthState ) . toHaveBeenCalledWith (
604+ expect . objectContaining ( {
605+ oauthClientInfo : expect . objectContaining ( {
606+ client_id : "test_client_id" ,
607+ } ) ,
608+ oauthStep : "authorization_redirect" ,
609+ } ) ,
610+ ) ;
611+
612+ // Verify the dynamically registered client info was persisted
613+ expect ( sessionStorage . setItem ) . toHaveBeenCalledWith (
614+ `[${ defaultProps . serverUrl } ] ${ SESSION_KEYS . CLIENT_INFORMATION } ` ,
615+ expect . any ( String ) ,
616+ ) ;
617+ } ) ;
618+ } ) ;
619+
445620 describe ( "OAuth State Persistence" , ( ) => {
446621 it ( "should store auth state to sessionStorage before redirect in Quick OAuth Flow" , async ( ) => {
447622 const updateAuthState = jest . fn ( ) ;
0 commit comments