f929c58c6b
* Add CSV output for list tools under insiders mode * fix: resolve rebase feature flag conflicts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify feature-flag handling: collapse CSV dual-variant + skip filtering when no checker (#2516) * refactor: generic toolset+name sort, clarify feature flag intent Address review feedback on #2450: - Collapse the three near-identical sort helpers in pkg/inventory/filters.go into a generic sortByToolsetThenName so adding new inventory item types doesn't require copying the comparator. - Expand the doc comments on the three *WithoutFeatureFiltering helpers to spell out why they exist: HTTP mode builds a static (process-wide) inventory as an upper bound, but per-request feature flags from headers (X-MCP-Features, X-MCP-Insiders) are evaluated later, so feature-flagged variants must be preserved here. - Strengthen the doc comment on ResolveFeatureFlags to make the contract explicit: user-supplied flags are validated against AllowedFeatureFlags, but insiders expansion deliberately is not — InsidersFeatureFlags may include server-controlled flags that are not user-toggleable. CORS comments are intentionally left for the PR author. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(feature-flags): clarify allowed and insiders sets are independent Also add tests covering: - a user-toggleable flag (FeatureFlagIssuesGranular) that insiders does not turn on automatically - insiders mode not turning on user-only allowed flags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(inventory): collapse three *WithoutFeatureFiltering helpers into StaticUpperBound The three parallel methods (AvailableToolsWithoutFeatureFiltering, AvailableResourceTemplatesWithoutFeatureFiltering, AvailablePromptsWithoutFeatureFiltering) were always called as a triple in exactly two places: HTTP buildStaticInventory and its test mirror. They exist because the dual-variant pattern (sibling tools with mirrored FeatureFlagEnable / FeatureFlagDisable on the same name, e.g. CSV output) makes feature filtering at static-build time impossible — both variants must be kept and resolved per-request. Replace the three with one method, Inventory.StaticUpperBound(ctx), that returns (tools, resources, prompts) and carries the rationale in its doc comment. Reduces API surface, eliminates the triplication, and makes the single "skip feature filtering" concept obvious to readers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: simplify feature-flag handling Two related simplifications, both about treating insiders as a meta flag that expands once at startup and then stops mattering: - Collapse CSV's dual-variant pattern into a single tool whose handler performs a runtime feature-flag check via deps.IsFeatureEnabled. CSV is a pure response-format toggle, not a schema change, so it does not need the dual-name pattern that genuine schema variants (granular issues/PRs) still use. - When no feature checker is installed, skip feature-flag filtering and return the full upper bound. The static HTTP inventory now uses plain AvailableTools/Resources/Prompts; the per-request inventory always installs a checker, so MCP registration (which serves a tool name once) always sees a deduplicated set. The bespoke StaticUpperBound helper and the isToolEnabledWithFeatureFlags split go away. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci(mcp-diff): add insiders + per-feature configs The mcp-diff matrix now includes: - --insiders (and --insiders --read-only) - one config per github.AllowedFeatureFlags entry, generated by script/print-mcp-diff-configs so new user-controllable flags get diffed automatically without editing the workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(insiders): explain feature-flag resolution for contributors Adds a 'How feature flags are resolved' section covering: - Insiders is a meta flag, like 'all'/'default' for toolsets - User input -> allowlist filter -> insiders expansion -> server-side fallback (remote only) - AllowedFeatureFlags vs InsidersFeatureFlags are independent - How to add a new feature flag, including the TestGitHubPackageDoesNotReadInsidersMode guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(inventory): make feature-flag gating a regular ToolFilter Move tool feature-flag evaluation out of isToolEnabled and into a ToolFilter installed at the head of the pipeline by Build() when WithFeatureChecker received a non-nil checker. The 'no checker = no filtering' contract is now expressed structurally (the filter isn't installed) instead of by a runtime nil check inside the helper. Resources and prompts have no filter pipeline, so they call the now-pure featureFlagAllowed helper behind an explicit r.featureChecker != nil guard at the iteration site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * perf(inventory): cache extracted toolset IDs in sort comparator Avoid evaluating the extractor closures up to three times per comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct MCP features header in cors * docs: regenerate README for CSV output toolset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove duplicate MCPFeaturesHeader from CORS headers * ci(mcp-diff): add streamable-http job with header-based configs Adds a sibling mcp-diff-http job that exercises the streamable-http transport against a shared HTTP server, with per-config settings supplied via X-MCP-* request headers — mirroring how the remote server is invoked in production (server-side defaults + per-user header overrides). The config generator gains a -transport flag: - stdio (default, unchanged behaviour) - http-headers (emits headers-only configs targeting a shared server) Two new combined entries layer multiple headers together as a smoke test for header-merging regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: regenerate after merging main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow <info@sam-morrow.com> Co-authored-by: sammorrowdrums <sammorrowdrums@github.com>
583 lines
18 KiB
Go
583 lines
18 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/github/github-mcp-server/internal/githubv4mock"
|
|
"github.com/github/github-mcp-server/internal/toolsnaps"
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/google/go-github/v87/github"
|
|
"github.com/shurcooL/githubv4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func Test_GetMe(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := GetMe(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
|
|
// Verify some basic very important properties
|
|
assert.Equal(t, "get_me", tool.Name)
|
|
assert.True(t, tool.Annotations.ReadOnlyHint, "get_me tool should be read-only")
|
|
|
|
// Setup mock user response
|
|
mockUser := &github.User{
|
|
Login: github.Ptr("testuser"),
|
|
Name: github.Ptr("Test User"),
|
|
Email: github.Ptr("test@example.com"),
|
|
Bio: github.Ptr("GitHub user for testing"),
|
|
Company: github.Ptr("Test Company"),
|
|
Location: github.Ptr("Test Location"),
|
|
HTMLURL: github.Ptr("https://github.com/testuser"),
|
|
CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
|
|
Type: github.Ptr("User"),
|
|
Hireable: github.Ptr(true),
|
|
TwitterUsername: github.Ptr("testuser_twitter"),
|
|
Plan: &github.Plan{
|
|
Name: github.Ptr("pro"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
clientErr string // if set, GetClient returns this error
|
|
requestArgs map[string]any
|
|
expectToolError bool
|
|
expectedUser *github.User
|
|
expectedToolErrMsg string
|
|
}{
|
|
{
|
|
name: "successful get user",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetUser: mockResponse(t, http.StatusOK, mockUser),
|
|
}),
|
|
requestArgs: map[string]any{},
|
|
expectToolError: false,
|
|
expectedUser: mockUser,
|
|
},
|
|
{
|
|
name: "successful get user with reason",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetUser: mockResponse(t, http.StatusOK, mockUser),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"reason": "Testing API",
|
|
},
|
|
expectToolError: false,
|
|
expectedUser: mockUser,
|
|
},
|
|
{
|
|
name: "getting client fails",
|
|
clientErr: "expected test error",
|
|
requestArgs: map[string]any{},
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "failed to get GitHub client: expected test error",
|
|
},
|
|
{
|
|
name: "get user fails",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetUser: badRequestHandler("expected test failure"),
|
|
}),
|
|
requestArgs: map[string]any{},
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "expected test failure",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var deps ToolDependencies
|
|
if tc.clientErr != "" {
|
|
deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()}
|
|
} else {
|
|
obs := stubExporters()
|
|
deps = BaseDeps{Client: mustNewGHClient(t, tc.mockedClient), Obsv: obs}
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(tc.requestArgs)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
|
|
if tc.expectToolError {
|
|
require.True(t, result.IsError, "expected tool call result to be an error")
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
|
|
return
|
|
}
|
|
|
|
require.False(t, result.IsError)
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the result
|
|
var returnedUser MinimalUser
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
|
|
require.NoError(t, err)
|
|
|
|
// Verify minimal user details
|
|
assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login)
|
|
assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL)
|
|
|
|
// Verify user details
|
|
require.NotNil(t, returnedUser.Details)
|
|
assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name)
|
|
assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email)
|
|
assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio)
|
|
assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company)
|
|
assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location)
|
|
assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable)
|
|
assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetMe_IFC_FeatureFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := GetMe(translations.NullTranslationHelper)
|
|
|
|
mockUser := &github.User{
|
|
Login: github.Ptr("testuser"),
|
|
HTMLURL: github.Ptr("https://github.com/testuser"),
|
|
CreatedAt: &github.Timestamp{Time: time.Now()},
|
|
}
|
|
mockedHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetUser: mockResponse(t, http.StatusOK, mockUser),
|
|
})
|
|
|
|
depsWithIFCFeature := func(enabled bool) *BaseDeps {
|
|
return NewBaseDeps(
|
|
mustNewGHClient(t, mockedHTTPClient), nil, nil, nil,
|
|
translations.NullTranslationHelper,
|
|
FeatureFlags{},
|
|
0,
|
|
func(_ context.Context, flagName string) (bool, error) {
|
|
return flagName == FeatureFlagIFCLabels && enabled, nil
|
|
},
|
|
stubExporters(),
|
|
)
|
|
}
|
|
|
|
t.Run("feature disabled omits ifc label from result meta", func(t *testing.T) {
|
|
deps := depsWithIFCFeature(false)
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(map[string]any{})
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled")
|
|
})
|
|
|
|
t.Run("feature enabled includes ifc label in result meta", func(t *testing.T) {
|
|
deps := depsWithIFCFeature(true)
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(map[string]any{})
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
require.NotNil(t, result.Meta, "result meta should be set when IFC labels are enabled")
|
|
ifcLabel, ok := result.Meta["ifc"]
|
|
require.True(t, ok, "result meta should contain ifc key")
|
|
|
|
ifcJSON, err := json.Marshal(ifcLabel)
|
|
require.NoError(t, err)
|
|
|
|
var ifcMap map[string]any
|
|
err = json.Unmarshal(ifcJSON, &ifcMap)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "trusted", ifcMap["integrity"])
|
|
assert.Equal(t, "public", ifcMap["confidentiality"])
|
|
})
|
|
}
|
|
|
|
func Test_GetTeams(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := GetTeams(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
|
|
assert.Equal(t, "get_teams", tool.Name)
|
|
assert.True(t, tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only")
|
|
|
|
mockUser := &github.User{
|
|
Login: github.Ptr("testuser"),
|
|
Name: github.Ptr("Test User"),
|
|
Email: github.Ptr("test@example.com"),
|
|
Bio: github.Ptr("GitHub user for testing"),
|
|
Company: github.Ptr("Test Company"),
|
|
Location: github.Ptr("Test Location"),
|
|
HTMLURL: github.Ptr("https://github.com/testuser"),
|
|
CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
|
|
Type: github.Ptr("User"),
|
|
Hireable: github.Ptr(true),
|
|
TwitterUsername: github.Ptr("testuser_twitter"),
|
|
Plan: &github.Plan{
|
|
Name: github.Ptr("pro"),
|
|
},
|
|
}
|
|
|
|
mockTeamsResponse := githubv4mock.DataResponse(map[string]any{
|
|
"user": map[string]any{
|
|
"organizations": map[string]any{
|
|
"nodes": []map[string]any{
|
|
{
|
|
"login": "testorg1",
|
|
"teams": map[string]any{
|
|
"nodes": []map[string]any{
|
|
{
|
|
"name": "team1",
|
|
"slug": "team1",
|
|
"description": "Team 1",
|
|
},
|
|
{
|
|
"name": "team2",
|
|
"slug": "team2",
|
|
"description": "Team 2",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"login": "testorg2",
|
|
"teams": map[string]any{
|
|
"nodes": []map[string]any{
|
|
{
|
|
"name": "team3",
|
|
"slug": "team3",
|
|
"description": "Team 3",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
mockNoTeamsResponse := githubv4mock.DataResponse(map[string]any{
|
|
"user": map[string]any{
|
|
"organizations": map[string]any{
|
|
"nodes": []map[string]any{},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Create GQL clients for different test scenarios - these are factory functions
|
|
// to ensure each test gets a fresh client
|
|
gqlClientForTestuser := func() *githubv4.Client {
|
|
queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
|
|
vars := map[string]any{
|
|
"login": "testuser",
|
|
}
|
|
matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse)
|
|
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
|
|
return githubv4.NewClient(httpClient)
|
|
}
|
|
|
|
gqlClientForSpecificuser := func() *githubv4.Client {
|
|
queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
|
|
vars := map[string]any{
|
|
"login": "specificuser",
|
|
}
|
|
matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse)
|
|
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
|
|
return githubv4.NewClient(httpClient)
|
|
}
|
|
|
|
gqlClientNoTeams := func() *githubv4.Client {
|
|
queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
|
|
vars := map[string]any{
|
|
"login": "testuser",
|
|
}
|
|
matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoTeamsResponse)
|
|
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
|
|
return githubv4.NewClient(httpClient)
|
|
}
|
|
|
|
// Factory function for mock HTTP clients with user response
|
|
httpClientWithUser := func() *http.Client {
|
|
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetUser: mockResponse(t, http.StatusOK, mockUser),
|
|
})
|
|
}
|
|
|
|
httpClientUserFails := func() *http.Client {
|
|
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetUser: badRequestHandler("expected test failure"),
|
|
})
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
makeDeps func() ToolDependencies
|
|
requestArgs map[string]any
|
|
expectToolError bool
|
|
expectedToolErrMsg string
|
|
expectedTeamsCount int
|
|
}{
|
|
{
|
|
name: "successful get teams",
|
|
makeDeps: func() ToolDependencies {
|
|
return BaseDeps{
|
|
Client: mustNewGHClient(t, httpClientWithUser()),
|
|
GQLClient: gqlClientForTestuser(),
|
|
}
|
|
},
|
|
requestArgs: map[string]any{},
|
|
expectToolError: false,
|
|
expectedTeamsCount: 2,
|
|
},
|
|
{
|
|
name: "successful get teams for specific user",
|
|
makeDeps: func() ToolDependencies {
|
|
return BaseDeps{
|
|
GQLClient: gqlClientForSpecificuser(),
|
|
}
|
|
},
|
|
requestArgs: map[string]any{
|
|
"user": "specificuser",
|
|
},
|
|
expectToolError: false,
|
|
expectedTeamsCount: 2,
|
|
},
|
|
{
|
|
name: "no teams found",
|
|
makeDeps: func() ToolDependencies {
|
|
return BaseDeps{
|
|
Client: mustNewGHClient(t, httpClientWithUser()),
|
|
GQLClient: gqlClientNoTeams(),
|
|
}
|
|
},
|
|
requestArgs: map[string]any{},
|
|
expectToolError: false,
|
|
expectedTeamsCount: 0,
|
|
},
|
|
{
|
|
name: "getting client fails",
|
|
makeDeps: func() ToolDependencies {
|
|
return stubDeps{clientFn: stubClientFnErr("expected test error"), obsv: stubExporters()}
|
|
},
|
|
requestArgs: map[string]any{},
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "failed to get GitHub client: expected test error",
|
|
},
|
|
{
|
|
name: "get user fails",
|
|
makeDeps: func() ToolDependencies {
|
|
return BaseDeps{
|
|
Client: mustNewGHClient(t, httpClientUserFails()),
|
|
Obsv: stubExporters(),
|
|
}
|
|
},
|
|
requestArgs: map[string]any{},
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "expected test failure",
|
|
},
|
|
{
|
|
name: "getting GraphQL client fails",
|
|
makeDeps: func() ToolDependencies {
|
|
return stubDeps{
|
|
clientFn: stubClientFnFromHTTP(t, httpClientWithUser()),
|
|
gqlClientFn: stubGQLClientFnErr("GraphQL client error"),
|
|
obsv: stubExporters(),
|
|
}
|
|
},
|
|
requestArgs: map[string]any{},
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
deps := tc.makeDeps()
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(tc.requestArgs)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
|
|
if tc.expectToolError {
|
|
require.True(t, result.IsError, "expected tool call result to be an error")
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
|
|
return
|
|
}
|
|
|
|
require.False(t, result.IsError)
|
|
textContent := getTextResult(t, result)
|
|
|
|
var organizations []OrganizationTeams
|
|
err = json.Unmarshal([]byte(textContent.Text), &organizations)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, organizations, tc.expectedTeamsCount)
|
|
|
|
if tc.expectedTeamsCount > 0 {
|
|
assert.Equal(t, "testorg1", organizations[0].Org)
|
|
assert.Len(t, organizations[0].Teams, 2)
|
|
assert.Equal(t, "team1", organizations[0].Teams[0].Name)
|
|
assert.Equal(t, "team1", organizations[0].Teams[0].Slug)
|
|
assert.Equal(t, "Team 1", organizations[0].Teams[0].Description)
|
|
|
|
if tc.expectedTeamsCount > 1 {
|
|
assert.Equal(t, "testorg2", organizations[1].Org)
|
|
assert.Len(t, organizations[1].Teams, 1)
|
|
assert.Equal(t, "team3", organizations[1].Teams[0].Name)
|
|
assert.Equal(t, "team3", organizations[1].Teams[0].Slug)
|
|
assert.Equal(t, "Team 3", organizations[1].Teams[0].Description)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetTeamMembers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := GetTeamMembers(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
|
|
assert.Equal(t, "get_team_members", tool.Name)
|
|
assert.True(t, tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only")
|
|
|
|
mockTeamMembersResponse := githubv4mock.DataResponse(map[string]any{
|
|
"organization": map[string]any{
|
|
"team": map[string]any{
|
|
"members": map[string]any{
|
|
"nodes": []map[string]any{
|
|
{
|
|
"login": "user1",
|
|
},
|
|
{
|
|
"login": "user2",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
mockNoMembersResponse := githubv4mock.DataResponse(map[string]any{
|
|
"organization": map[string]any{
|
|
"team": map[string]any{
|
|
"members": map[string]any{
|
|
"nodes": []map[string]any{},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Create GQL clients for different test scenarios
|
|
gqlClientWithMembers := func() *githubv4.Client {
|
|
queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}"
|
|
vars := map[string]any{
|
|
"org": "testorg",
|
|
"teamSlug": "testteam",
|
|
}
|
|
matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamMembersResponse)
|
|
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
|
|
return githubv4.NewClient(httpClient)
|
|
}
|
|
|
|
gqlClientNoMembers := func() *githubv4.Client {
|
|
queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}"
|
|
vars := map[string]any{
|
|
"org": "testorg",
|
|
"teamSlug": "emptyteam",
|
|
}
|
|
matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoMembersResponse)
|
|
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
|
|
return githubv4.NewClient(httpClient)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
deps ToolDependencies
|
|
requestArgs map[string]any
|
|
expectToolError bool
|
|
expectedToolErrMsg string
|
|
expectedMembersCount int
|
|
}{
|
|
{
|
|
name: "successful get team members",
|
|
deps: BaseDeps{GQLClient: gqlClientWithMembers()},
|
|
requestArgs: map[string]any{
|
|
"org": "testorg",
|
|
"team_slug": "testteam",
|
|
},
|
|
expectToolError: false,
|
|
expectedMembersCount: 2,
|
|
},
|
|
{
|
|
name: "team with no members",
|
|
deps: BaseDeps{GQLClient: gqlClientNoMembers()},
|
|
requestArgs: map[string]any{
|
|
"org": "testorg",
|
|
"team_slug": "emptyteam",
|
|
},
|
|
expectToolError: false,
|
|
expectedMembersCount: 0,
|
|
},
|
|
{
|
|
name: "getting GraphQL client fails",
|
|
deps: stubDeps{gqlClientFn: stubGQLClientFnErr("GraphQL client error"), obsv: stubExporters()},
|
|
requestArgs: map[string]any{
|
|
"org": "testorg",
|
|
"team_slug": "testteam",
|
|
},
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
handler := serverTool.Handler(tc.deps)
|
|
|
|
request := createMCPRequest(tc.requestArgs)
|
|
result, err := handler(ContextWithDeps(context.Background(), tc.deps), &request)
|
|
require.NoError(t, err)
|
|
|
|
if tc.expectToolError {
|
|
require.True(t, result.IsError, "expected tool call result to be an error")
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
|
|
return
|
|
}
|
|
|
|
require.False(t, result.IsError)
|
|
textContent := getTextResult(t, result)
|
|
|
|
var members []string
|
|
err = json.Unmarshal([]byte(textContent.Text), &members)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, members, tc.expectedMembersCount)
|
|
|
|
if tc.expectedMembersCount > 0 {
|
|
assert.Equal(t, "user1", members[0])
|
|
|
|
if tc.expectedMembersCount > 1 {
|
|
assert.Equal(t, "user2", members[1])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|