@@ -25,6 +25,7 @@ const mockOAuthMetadata = {
25
25
token_endpoint : "https://oauth.example.com/token" ,
26
26
response_types_supported : [ "code" ] ,
27
27
grant_types_supported : [ "authorization_code" ] ,
28
+ scopes_supported : [ "read" , "write" ] ,
28
29
} ;
29
30
30
31
const mockOAuthClientInfo = {
@@ -56,6 +57,57 @@ import {
56
57
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js" ;
57
58
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types" ;
58
59
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
+
59
111
// Type the mocked functions properly
60
112
const mockDiscoverAuthorizationServerMetadata =
61
113
discoverAuthorizationServerMetadata as jest . MockedFunction <
@@ -75,6 +127,9 @@ const mockDiscoverOAuthProtectedResourceMetadata =
75
127
discoverOAuthProtectedResourceMetadata as jest . MockedFunction <
76
128
typeof discoverOAuthProtectedResourceMetadata
77
129
> ;
130
+ const mockDiscoverScopes = discoverScopes as jest . MockedFunction <
131
+ typeof discoverScopes
132
+ > ;
78
133
79
134
const sessionStorageMock = {
80
135
getItem : jest . fn ( ) ,
@@ -103,9 +158,15 @@ describe("AuthDebugger", () => {
103
158
// Suppress console errors in tests to avoid JSDOM navigation noise
104
159
jest . spyOn ( console , "error" ) . mockImplementation ( ( ) => { } ) ;
105
160
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
+ } ) ;
109
170
mockRegisterClient . mockResolvedValue ( mockOAuthClientInfo ) ;
110
171
mockDiscoverOAuthProtectedResourceMetadata . mockRejectedValue (
111
172
new Error ( "No protected resource metadata found" ) ,
@@ -427,7 +488,24 @@ describe("AuthDebugger", () => {
427
488
} ) ;
428
489
} ) ;
429
490
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
+
431
509
const updateAuthState =
432
510
await setupAuthorizationUrlTest ( mockOAuthMetadata ) ;
433
511
@@ -442,6 +520,103 @@ describe("AuthDebugger", () => {
442
520
} ) ;
443
521
} ) ;
444
522
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
+
445
620
describe ( "OAuth State Persistence" , ( ) => {
446
621
it ( "should store auth state to sessionStorage before redirect in Quick OAuth Flow" , async ( ) => {
447
622
const updateAuthState = jest . fn ( ) ;
0 commit comments