@@ -5,13 +5,18 @@ import (
55 "encoding/json"
66 "io"
77 "net/http"
8+ "net/http/httptest"
89 "reflect"
910 "strings"
1011 "testing"
1112
1213 ghcontext "github.com/github/github-mcp-server/pkg/context"
14+ "github.com/github/github-mcp-server/pkg/http/middleware"
15+ "github.com/github/github-mcp-server/pkg/http/oauth"
1316 "github.com/github/github-mcp-server/pkg/inventory"
17+ "github.com/github/github-mcp-server/pkg/scopes"
1418 "github.com/github/github-mcp-server/pkg/translations"
19+ "github.com/github/github-mcp-server/pkg/utils"
1520 "github.com/google/jsonschema-go/jsonschema"
1621 "github.com/modelcontextprotocol/go-sdk/mcp"
1722 "github.com/shurcooL/githubv4"
@@ -274,12 +279,12 @@ func TestTypedUIGetNonemptyWireOutputs(t *testing.T) {
274279 structured string
275280 }{
276281 {method : "labels" , args : map [string ]any {"method" : "labels" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"labels","labels":{"labels":[{"id":"LABEL_1","name":"bug","color":"d73a4a","description":"Something isn't working"}],"totalCount":1,"has_more":false}}` },
277- {method : "assignees" , args : map [string ]any {"method" : "assignees" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"assignees","assignees":{"assignees":[{"login":"octocat"}],"totalCount":1,"has_more":false}}` },
282+ {method : "assignees" , args : map [string ]any {"method" : "assignees" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"assignees","assignees":{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png" }],"totalCount":1,"has_more":false}}` },
278283 {method : "milestones" , args : map [string ]any {"method" : "milestones" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"milestones","milestones":{"milestones":[{"number":4,"title":"v1","description":"First release","state":"open","open_issues":2,"due_on":"2026-12-31"}],"totalCount":1,"has_more":false}}` },
279284 {method : "issue_types" , args : map [string ]any {"method" : "issue_types" , "owner" : "owner" }, structured : `{"method":"issue_types","issue_types":[{"id":12,"name":"Bug","description":"A bug","color":"red"},null]}` },
280285 {method : "branches" , args : map [string ]any {"method" : "branches" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"branches","branches":{"branches":[{"name":"main","sha":"abc123","protected":true}],"totalCount":1,"has_more":false}}` },
281286 {method : "issue_fields" , args : map [string ]any {"method" : "issue_fields" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"issue_fields","issue_fields":{"fields":[{"id":"FIELD_1","name":"Priority","data_type":"text","description":"Issue priority"}],"totalCount":1}}` },
282- {method : "reviewers" , args : map [string ]any {"method" : "reviewers" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"reviewers","reviewers":{"users":[{"login":"octocat"}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}` },
287+ {method : "reviewers" , args : map [string ]any {"method" : "reviewers" , "owner" : "owner" , "repo" : "repo" }, structured : `{"method":"reviewers","reviewers":{"users":[{"login":"octocat","avatar_url":"https://example.com/avatar.png" }],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}` },
283288 }
284289 for _ , tc := range tests {
285290 t .Run (tc .method , func (t * testing.T ) {
@@ -337,6 +342,8 @@ func TestTypedCopilotUIErrorsPreserveLegacyText(t *testing.T) {
337342 {name : "request_copilot_review" , args : map [string ]any {"repo" : "repo" , "pullNumber" : 7 }, text : "missing required parameter: owner" },
338343 {name : "ui_get" , args : map [string ]any {"method" : "labels" , "owner" : "owner" }, text : "missing required parameter: repo" },
339344 {name : "ui_get" , args : map [string ]any {"method" : "unknown" , "owner" : "owner" }, text : "unknown method: unknown" },
345+ {name : "ui_get" , args : map [string ]any {"method" : "unknown" , "owner" : "owner" , "repo" : true }, text : "unknown method: unknown" },
346+ {name : "ui_get" , args : map [string ]any {"method" : "unknown" }, text : "missing required parameter: owner" },
340347 {name : "ui_get" , args : map [string ]any {"method" : true , "owner" : "owner" }, text : "parameter method is not of type string" },
341348 }
342349 for _ , protocol := range protocols {
@@ -354,20 +361,88 @@ func TestTypedCopilotUIErrorsPreserveLegacyText(t *testing.T) {
354361 }
355362}
356363
364+ func TestTypedUIGetHTTPScopesAndDispatch (t * testing.T ) {
365+ registry , err := inventory .NewBuilder ().SetTools ([]inventory.ServerTool {
366+ UIGet (translations .NullTranslationHelper ),
367+ }).WithToolsets ([]string {"all" }).Build ()
368+ require .NoError (t , err )
369+ scopes .SetToolScopeMapFromInventory (registry )
370+ t .Cleanup (func () { scopes .SetGlobalToolScopeMap (nil ) })
371+
372+ for _ , protocol := range []string {inventory .ProtocolVersionMultiRoundTrip , "2025-11-25" , "" , "unknown" } {
373+ t .Run ("protocol=" + protocol , func (t * testing.T ) {
374+ session , _ := typedCopilotUISession (t , typedCopilotUIClient (t ), protocol )
375+ for _ , tc := range []struct {
376+ name string
377+ args map [string ]any
378+ scopes []string
379+ wantStatus int
380+ wantError string
381+ }{
382+ {name : "unknown method does not challenge" , args : map [string ]any {"method" : "unknown" , "owner" : "owner" , "repo" : "repo" }, scopes : []string {"gist" }, wantStatus : http .StatusOK , wantError : "unknown method: unknown" },
383+ {name : "unknown method validates owner first" , args : map [string ]any {"method" : "unknown" , "repo" : "repo" }, scopes : []string {"gist" }, wantStatus : http .StatusOK , wantError : "missing required parameter: owner" },
384+ {name : "labels cannot be overridden to issue types" , args : map [string ]any {"method" : "labels" , "owner" : "owner" , "repo" : "repo" , "_compat_method" : "issue_types" }, scopes : []string {"repo" }, wantStatus : http .StatusOK },
385+ {name : "labels cannot be overridden to reviewers" , args : map [string ]any {"method" : "labels" , "owner" : "owner" , "repo" : "repo" , "_compat_method" : "reviewers" }, scopes : []string {"repo" }, wantStatus : http .StatusOK },
386+ {name : "issue types still require read org" , args : map [string ]any {"method" : "issue_types" , "owner" : "owner" , "_compat_method" : "labels" }, scopes : []string {"repo" }, wantStatus : http .StatusForbidden },
387+ {name : "labels still require repo" , args : map [string ]any {"method" : "labels" , "owner" : "owner" , "repo" : "repo" }, scopes : []string {"gist" }, wantStatus : http .StatusForbidden },
388+ } {
389+ t .Run (tc .name , func (t * testing.T ) {
390+ raw , err := json .Marshal (tc .args )
391+ require .NoError (t , err )
392+ called := false
393+ handler := middleware .WithScopeChallenge (& oauth.Config {}, nil )(http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
394+ called = true
395+ result , err := session .CallTool (r .Context (), & mcp.CallToolParams {Name : "ui_get" , Arguments : tc .args })
396+ require .NoError (t , err )
397+ if tc .wantError != "" {
398+ require .True (t , result .IsError )
399+ assert .Equal (t , tc .wantError , getTextResult (t , result ).Text )
400+ assert .Nil (t , result .StructuredContent )
401+ } else {
402+ require .False (t , result .IsError )
403+ if protocol == inventory .ProtocolVersionMultiRoundTrip {
404+ assert .JSONEq (t , `{"method":"labels","labels":{"labels":[],"totalCount":0,"has_more":false}}` , getTextResult (t , result ).Text )
405+ } else {
406+ assert .Equal (t , `{"has_more":false,"labels":[],"totalCount":0}` , getTextResult (t , result ).Text )
407+ }
408+ }
409+ require .NoError (t , json .NewEncoder (w ).Encode (result ))
410+ }))
411+ request := httptest .NewRequest (http .MethodPost , "/mcp" , nil )
412+ ctx := ghcontext .WithTokenInfo (request .Context (), & ghcontext.TokenInfo {TokenType : utils .TokenTypeOAuthAccessToken })
413+ ctx = ghcontext .WithTokenScopes (ctx , tc .scopes )
414+ ctx = ghcontext .WithMCPMethodInfo (ctx , & ghcontext.MCPMethodInfo {
415+ Method : "tools/call" , ItemName : "ui_get" , RawArguments : raw , ProtocolVersion : protocol ,
416+ })
417+ response := httptest .NewRecorder ()
418+ handler .ServeHTTP (response , request .WithContext (ctx ))
419+ assert .Equal (t , tc .wantStatus , response .Code )
420+ assert .Equal (t , tc .wantStatus == http .StatusOK , called )
421+ if tc .wantStatus == http .StatusOK {
422+ assert .Empty (t , response .Header ().Get ("WWW-Authenticate" ))
423+ } else {
424+ assert .Contains (t , response .Header ().Get ("WWW-Authenticate" ), "insufficient_scope" )
425+ }
426+ })
427+ }
428+ })
429+ }
430+ }
431+
357432func TestTypedUIGetUnionOutputs (t * testing.T ) {
358433 cases := []struct {
359434 method string
360435 raw string
361436 typed string
362437 }{
363438 {method : "labels" , raw : `{"labels":[{"id":"L","name":"bug","color":"red","description":""}],"totalCount":1,"has_more":false}` , typed : `{"method":"labels","labels":{"labels":[{"id":"L","name":"bug","color":"red","description":""}],"totalCount":1,"has_more":false}}` },
364- {method : "assignees" , raw : `{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}` , typed : `{"method":"assignees","assignees":{"assignees":[{"login":"octocat"}],"totalCount":1,"has_more":false}}` },
439+ {method : "assignees" , raw : `{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}` , typed : `{"method":"assignees","assignees":{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png" }],"totalCount":1,"has_more":false}}` },
365440 {method : "milestones" , raw : `{"milestones":[{"number":4,"title":"v1","description":"","state":"open","open_issues":2,"due_on":""}],"totalCount":1,"has_more":false}` , typed : `{"method":"milestones","milestones":{"milestones":[{"number":4,"title":"v1","description":"","state":"open","open_issues":2,"due_on":""}],"totalCount":1,"has_more":false}}` },
366441 {method : "issue_types" , raw : `[{"id":1,"name":"Bug"}]` , typed : `{"method":"issue_types","issue_types":[{"id":1,"name":"Bug"}]}` },
367442 {method : "issue_types" , raw : `null` , typed : `{"method":"issue_types","issue_types":null}` },
368443 {method : "branches" , raw : `{"branches":[{"name":"main","sha":"abc","protected":true}],"totalCount":1,"has_more":false}` , typed : `{"method":"branches","branches":{"branches":[{"name":"main","sha":"abc","protected":true}],"totalCount":1,"has_more":false}}` },
369444 {method : "issue_fields" , raw : `{"fields":[{"id":"F","name":"Priority","data_type":"single_select","description":"","options":[]}],"totalCount":1}` , typed : `{"method":"issue_fields","issue_fields":{"fields":[{"id":"F","name":"Priority","data_type":"single_select","description":"","options":[]}],"totalCount":1}}` },
370- {method : "reviewers" , raw : `{"users":[{"login":"octocat","avatar_url":""}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}` , typed : `{"method":"reviewers","reviewers":{"users":[{"login":"octocat"}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}` },
445+ {method : "reviewers" , raw : `{"users":[{"login":"octocat","avatar_url":""}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}` , typed : `{"method":"reviewers","reviewers":{"users":[{"login":"octocat","avatar_url":"" }],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}` },
371446 }
372447 schema , err := uiGetOutputSchema ().Resolve (nil )
373448 require .NoError (t , err )
0 commit comments