Skip to content

Commit b4ace27

Browse files
committed
feat: add org membership lookup tool
1 parent 96f6fd3 commit b4ace27

7 files changed

Lines changed: 321 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,12 @@ The following sets of tools are available:
974974

975975
<summary><picture><source media="(prefers-color-scheme: dark)" srcset="pkg/octicons/icons/organization-dark.png"><source media="(prefers-color-scheme: light)" srcset="pkg/octicons/icons/organization-light.png"><img src="pkg/octicons/icons/organization-light.png" width="20" height="20" alt="organization"></picture> Organizations</summary>
976976

977+
- **check_org_membership** - Check organization membership
978+
- **Required OAuth Scopes**: `read:org`
979+
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
980+
- `org`: GitHub organization login (string, required)
981+
- `username`: GitHub username to check (string, required)
982+
977983
- **search_orgs** - Search organizations
978984
- **Required OAuth Scopes**: `read:org`
979985
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`

docs/server-configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
1010
| Toolsets | `X-MCP-Toolsets` header or `/x/{toolset}` URL | `--toolsets` flag or `GITHUB_TOOLSETS` env var |
1111
| Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var |
1212
| Exclude Tools | `X-MCP-Exclude-Tools` header | `--exclude-tools` flag or `GITHUB_EXCLUDE_TOOLS` env var |
13+
| Organization Membership Lookup | Enable `orgs` toolset or `check_org_membership` tool | Enable `orgs` toolset or `check_org_membership` tool |
1314
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
1415
| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var |
1516
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "Check organization membership"
5+
},
6+
"description": "Check whether a GitHub user is a member of an organization, and report whether that membership is public, private, or not visible.",
7+
"inputSchema": {
8+
"properties": {
9+
"org": {
10+
"description": "GitHub organization login",
11+
"type": "string"
12+
},
13+
"username": {
14+
"description": "GitHub username to check",
15+
"type": "string"
16+
}
17+
},
18+
"required": [
19+
"org",
20+
"username"
21+
],
22+
"type": "object"
23+
},
24+
"name": "check_org_membership"
25+
}

pkg/github/helper_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ const (
140140
GetSearchUsers = "GET /search/users"
141141
GetSearchRepositories = "GET /search/repositories"
142142

143+
// Organization endpoints
144+
GetOrgsByOrg = "GET /orgs/{org}"
145+
GetOrgsMembersByOrgByUsername = "GET /orgs/{org}/members/{username}"
146+
GetOrgsPublicMembersByOrgByUsername = "GET /orgs/{org}/public_members/{username}"
147+
143148
// Raw content endpoints (used for GitHub raw content API, not standard API)
144149
// These are used with the raw content client that interacts with raw.githubusercontent.com
145150
GetRawReposContentsByOwnerByRepoByPath = "GET /{owner}/{repo}/HEAD/{path:.*}"

pkg/github/orgs.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
8+
ghErrors "github.com/github/github-mcp-server/pkg/errors"
9+
"github.com/github/github-mcp-server/pkg/inventory"
10+
"github.com/github/github-mcp-server/pkg/scopes"
11+
"github.com/github/github-mcp-server/pkg/translations"
12+
"github.com/github/github-mcp-server/pkg/utils"
13+
"github.com/google/jsonschema-go/jsonschema"
14+
"github.com/modelcontextprotocol/go-sdk/mcp"
15+
)
16+
17+
const orgMembershipVisibilityNote = "result reflects caller visibility; private members of orgs you can't see appear as non-members."
18+
19+
type CheckOrgMembershipInput struct {
20+
Org string `json:"org"`
21+
Username string `json:"username"`
22+
}
23+
24+
type CheckOrgMembershipOutput struct {
25+
Org string `json:"org"`
26+
Username string `json:"username"`
27+
IsMember bool `json:"isMember"`
28+
Visibility string `json:"visibility"`
29+
Note string `json:"note,omitempty"`
30+
}
31+
32+
// CheckOrgMembership creates a tool to check whether a GitHub user is a member of an organization.
33+
func CheckOrgMembership(t translations.TranslationHelperFunc) inventory.ServerTool {
34+
return NewTool[CheckOrgMembershipInput, CheckOrgMembershipOutput](
35+
ToolsetMetadataOrgs,
36+
mcp.Tool{
37+
Name: "check_org_membership",
38+
Description: t("TOOL_CHECK_ORG_MEMBERSHIP_DESCRIPTION", "Check whether a GitHub user is a member of an organization, and report whether that membership is public, private, or not visible."),
39+
Annotations: &mcp.ToolAnnotations{
40+
Title: t("TOOL_CHECK_ORG_MEMBERSHIP_USER_TITLE", "Check organization membership"),
41+
ReadOnlyHint: true,
42+
},
43+
InputSchema: &jsonschema.Schema{
44+
Type: "object",
45+
Properties: map[string]*jsonschema.Schema{
46+
"org": {
47+
Type: "string",
48+
Description: "GitHub organization login",
49+
},
50+
"username": {
51+
Type: "string",
52+
Description: "GitHub username to check",
53+
},
54+
},
55+
Required: []string{"org", "username"},
56+
},
57+
},
58+
[]scopes.Scope{scopes.ReadOrg},
59+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args CheckOrgMembershipInput) (*mcp.CallToolResult, CheckOrgMembershipOutput, error) {
60+
if args.Org == "" {
61+
return utils.NewToolResultError("missing required parameter: org"), CheckOrgMembershipOutput{}, nil
62+
}
63+
if args.Username == "" {
64+
return utils.NewToolResultError("missing required parameter: username"), CheckOrgMembershipOutput{}, nil
65+
}
66+
67+
client, err := deps.GetClient(ctx)
68+
if err != nil {
69+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), CheckOrgMembershipOutput{}, nil
70+
}
71+
72+
isMember, res, err := client.Organizations.IsMember(ctx, args.Org, args.Username)
73+
if err != nil {
74+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
75+
"failed to check organization membership",
76+
res,
77+
err,
78+
), CheckOrgMembershipOutput{}, nil
79+
}
80+
81+
isPublicMember, res, err := client.Organizations.IsPublicMember(ctx, args.Org, args.Username)
82+
if err != nil {
83+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
84+
"failed to check public organization membership",
85+
res,
86+
err,
87+
), CheckOrgMembershipOutput{}, nil
88+
}
89+
90+
output := CheckOrgMembershipOutput{
91+
Org: args.Org,
92+
Username: args.Username,
93+
}
94+
switch {
95+
case isPublicMember:
96+
output.IsMember = true
97+
output.Visibility = "public"
98+
case isMember:
99+
output.IsMember = true
100+
output.Visibility = "private"
101+
default:
102+
if errResult := verifyOrganizationExists(ctx, args.Org, deps); errResult != nil {
103+
return errResult, CheckOrgMembershipOutput{}, nil
104+
}
105+
output.Visibility = "none"
106+
output.Note = orgMembershipVisibilityNote
107+
}
108+
109+
r, err := json.Marshal(output)
110+
if err != nil {
111+
return nil, CheckOrgMembershipOutput{}, err
112+
}
113+
return utils.NewToolResultText(string(r)), output, nil
114+
},
115+
)
116+
}
117+
118+
func verifyOrganizationExists(ctx context.Context, org string, deps ToolDependencies) *mcp.CallToolResult {
119+
client, err := deps.GetClient(ctx)
120+
if err != nil {
121+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err)
122+
}
123+
124+
_, res, err := client.Organizations.Get(ctx, org)
125+
if err == nil {
126+
return nil
127+
}
128+
if res != nil && res.Response != nil && res.Response.StatusCode == http.StatusNotFound {
129+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
130+
"failed to get organization",
131+
res,
132+
err,
133+
)
134+
}
135+
136+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
137+
"failed to verify organization exists",
138+
res,
139+
err,
140+
)
141+
}

pkg/github/orgs_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/github/github-mcp-server/internal/toolsnaps"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
gogithub "github.com/google/go-github/v82/github"
12+
"github.com/google/jsonschema-go/jsonschema"
13+
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func Test_CheckOrgMembership(t *testing.T) {
19+
serverTool := CheckOrgMembership(translations.NullTranslationHelper)
20+
tool := serverTool.Tool
21+
22+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
23+
24+
assert.Equal(t, "check_org_membership", tool.Name)
25+
assert.NotEmpty(t, tool.Description)
26+
27+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
28+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
29+
assert.Contains(t, schema.Properties, "org")
30+
assert.Contains(t, schema.Properties, "username")
31+
assert.ElementsMatch(t, schema.Required, []string{"org", "username"})
32+
assert.True(t, serverTool.IsReadOnly())
33+
assert.ElementsMatch(t, []string{"read:org"}, serverTool.RequiredScopes)
34+
}
35+
36+
func Test_CheckOrgMembership_PublicMember(t *testing.T) {
37+
output := runCheckOrgMembership(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
38+
GetOrgsMembersByOrgByUsername: expectPath(t, "/orgs/canonical/members/octocat").andThen(mockStatus(http.StatusNoContent)),
39+
GetOrgsPublicMembersByOrgByUsername: expectPath(t, "/orgs/canonical/public_members/octocat").andThen(mockStatus(http.StatusNoContent)),
40+
}), false)
41+
42+
assert.Equal(t, CheckOrgMembershipOutput{
43+
Org: "canonical",
44+
Username: "octocat",
45+
IsMember: true,
46+
Visibility: "public",
47+
}, output)
48+
}
49+
50+
func Test_CheckOrgMembership_PrivateMember(t *testing.T) {
51+
output := runCheckOrgMembership(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
52+
GetOrgsMembersByOrgByUsername: expectPath(t, "/orgs/canonical/members/octocat").andThen(mockStatus(http.StatusNoContent)),
53+
GetOrgsPublicMembersByOrgByUsername: expectPath(t, "/orgs/canonical/public_members/octocat").andThen(mockStatus(http.StatusNotFound)),
54+
}), false)
55+
56+
assert.Equal(t, CheckOrgMembershipOutput{
57+
Org: "canonical",
58+
Username: "octocat",
59+
IsMember: true,
60+
Visibility: "private",
61+
}, output)
62+
}
63+
64+
func Test_CheckOrgMembership_NotAMember(t *testing.T) {
65+
output := runCheckOrgMembership(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
66+
GetOrgsMembersByOrgByUsername: expectPath(t, "/orgs/canonical/members/octocat").andThen(mockStatus(http.StatusNotFound)),
67+
GetOrgsPublicMembersByOrgByUsername: expectPath(t, "/orgs/canonical/public_members/octocat").andThen(mockStatus(http.StatusNotFound)),
68+
GetOrgsByOrg: expectPath(t, "/orgs/canonical").andThen(mockResponse(t, http.StatusOK, &gogithub.Organization{Login: gogithub.Ptr("canonical")})),
69+
}), false)
70+
71+
assert.Equal(t, "canonical", output.Org)
72+
assert.Equal(t, "octocat", output.Username)
73+
assert.False(t, output.IsMember)
74+
assert.Equal(t, "none", output.Visibility)
75+
assert.Equal(t, "result reflects caller visibility; private members of orgs you can't see appear as non-members.", output.Note)
76+
}
77+
78+
func Test_CheckOrgMembership_OrgNotFound(t *testing.T) {
79+
text := runCheckOrgMembershipError(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
80+
GetOrgsMembersByOrgByUsername: expectPath(t, "/orgs/missing-org/members/octocat").andThen(mockStatus(http.StatusNotFound)),
81+
GetOrgsPublicMembersByOrgByUsername: expectPath(t, "/orgs/missing-org/public_members/octocat").andThen(mockStatus(http.StatusNotFound)),
82+
GetOrgsByOrg: expectPath(t, "/orgs/missing-org").andThen(mockResponse(t, http.StatusNotFound, map[string]string{"message": "Not Found"})),
83+
}), map[string]any{"org": "missing-org", "username": "octocat"})
84+
85+
assert.Contains(t, text, "failed to get organization")
86+
assert.Contains(t, text, "404")
87+
}
88+
89+
func Test_CheckOrgMembership_ScopeError(t *testing.T) {
90+
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
91+
t.Run(http.StatusText(status), func(t *testing.T) {
92+
text := runCheckOrgMembershipError(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
93+
GetOrgsMembersByOrgByUsername: expectPath(t, "/orgs/canonical/members/octocat").andThen(
94+
mockResponse(t, status, map[string]string{"message": http.StatusText(status)}),
95+
),
96+
}), map[string]any{"org": "canonical", "username": "octocat"})
97+
98+
assert.Contains(t, text, "failed to check organization membership")
99+
assert.Contains(t, text, http.StatusText(status))
100+
})
101+
}
102+
}
103+
104+
func runCheckOrgMembership(t *testing.T, httpClient *http.Client, expectError bool) CheckOrgMembershipOutput {
105+
t.Helper()
106+
107+
result := callCheckOrgMembership(t, httpClient, map[string]any{"org": "canonical", "username": "octocat"})
108+
require.Equal(t, expectError, result.IsError)
109+
110+
textContent := getTextResult(t, result)
111+
var output CheckOrgMembershipOutput
112+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &output))
113+
return output
114+
}
115+
116+
func runCheckOrgMembershipError(t *testing.T, httpClient *http.Client, args map[string]any) string {
117+
t.Helper()
118+
119+
result := callCheckOrgMembership(t, httpClient, args)
120+
textContent := getErrorResult(t, result)
121+
return textContent.Text
122+
}
123+
124+
func callCheckOrgMembership(t *testing.T, httpClient *http.Client, args map[string]any) *mcp.CallToolResult {
125+
t.Helper()
126+
127+
client := gogithub.NewClient(httpClient)
128+
deps := BaseDeps{Client: client}
129+
serverTool := CheckOrgMembership(translations.NullTranslationHelper)
130+
handler := serverTool.Handler(deps)
131+
request := createMCPRequest(args)
132+
133+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
134+
require.NoError(t, err)
135+
return result
136+
}
137+
138+
func mockStatus(status int) http.HandlerFunc {
139+
return func(w http.ResponseWriter, _ *http.Request) {
140+
w.WriteHeader(status)
141+
}
142+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
217217

218218
// Organization tools
219219
SearchOrgs(t),
220+
CheckOrgMembership(t),
220221

221222
// Pull request tools
222223
PullRequestRead(t),

0 commit comments

Comments
 (0)