diff --git a/README.md b/README.md index 6413b58760..7ca8b1f54b 100644 --- a/README.md +++ b/README.md @@ -1144,6 +1144,13 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - **OAuth Challenge Scopes**: `notifications` - `notificationID`: The ID of the notification (string, required) +- **get_subject_subscription** - Get subject subscription + - **OAuth Challenge Scopes**: `repo` + - `number`: Issue, pull request, or discussion number (integer, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `subjectType`: Type of the subject to subscribe to (string, required) + - **list_notifications** - List notifications - **OAuth Challenge Scopes**: `notifications` - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional) @@ -1165,6 +1172,14 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - `owner`: The account owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) +- **manage_subject_subscription** - Manage subject subscription + - **OAuth Challenge Scopes**: `repo`, `notifications` + - `number`: Issue, pull request, or discussion number (integer, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: subscribed: receive all updates; unsubscribed: only participating or mentions; ignored: receive no notifications (string, required) + - `subjectType`: Type of the subject to subscribe to (string, required) + - **mark_all_notifications_read** - Mark all notifications as read - **OAuth Challenge Scopes**: `notifications` - `lastReadAt`: Describes the last point that notifications were checked (optional). Default: Now (string, optional) diff --git a/pkg/github/__toolsnaps__/get_subject_subscription.snap b/pkg/github/__toolsnaps__/get_subject_subscription.snap new file mode 100644 index 0000000000..734a2b53dd --- /dev/null +++ b/pkg/github/__toolsnaps__/get_subject_subscription.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get subject subscription" + }, + "description": "Get the authenticated user's notification subscription state for a specific issue, pull request, or discussion by repository and number. Does not require an existing notification thread.", + "inputSchema": { + "properties": { + "number": { + "description": "Issue, pull request, or discussion number", + "maximum": 2147483647, + "minimum": 1, + "type": "integer" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "subjectType": { + "description": "Type of the subject to subscribe to", + "enum": [ + "issue", + "pull_request", + "discussion" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "subjectType", + "number" + ], + "type": "object" + }, + "name": "get_subject_subscription" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_subject_subscription_output.snap b/pkg/github/__toolsnaps__/get_subject_subscription_output.snap new file mode 100644 index 0000000000..689089d1dd --- /dev/null +++ b/pkg/github/__toolsnaps__/get_subject_subscription_output.snap @@ -0,0 +1,44 @@ +{ + "inputSchema": null, + "name": "get_subject_subscription", + "outputSchema": { + "additionalProperties": false, + "properties": { + "can_subscribe": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "number": { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "subject_type": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "subject_type", + "number", + "url", + "state", + "can_subscribe" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_subject_subscription.snap b/pkg/github/__toolsnaps__/manage_subject_subscription.snap new file mode 100644 index 0000000000..32fe0c64af --- /dev/null +++ b/pkg/github/__toolsnaps__/manage_subject_subscription.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "readOnlyHint": false, + "title": "Manage subject subscription" + }, + "description": "Subscribe, unsubscribe, or ignore a specific issue, pull request, or discussion for the authenticated user by repository and number. Does not require an existing notification thread and does not watch the whole repository. Use get_subject_subscription to check the current state.", + "inputSchema": { + "properties": { + "number": { + "description": "Issue, pull request, or discussion number", + "maximum": 2147483647, + "minimum": 1, + "type": "integer" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "state": { + "description": "subscribed: receive all updates; unsubscribed: only participating or mentions; ignored: receive no notifications", + "enum": [ + "subscribed", + "unsubscribed", + "ignored" + ], + "type": "string" + }, + "subjectType": { + "description": "Type of the subject to subscribe to", + "enum": [ + "issue", + "pull_request", + "discussion" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "subjectType", + "number", + "state" + ], + "type": "object" + }, + "name": "manage_subject_subscription" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_subject_subscription_output.snap b/pkg/github/__toolsnaps__/manage_subject_subscription_output.snap new file mode 100644 index 0000000000..5bc9f9cad7 --- /dev/null +++ b/pkg/github/__toolsnaps__/manage_subject_subscription_output.snap @@ -0,0 +1,44 @@ +{ + "inputSchema": null, + "name": "manage_subject_subscription", + "outputSchema": { + "additionalProperties": false, + "properties": { + "can_subscribe": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "number": { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "subject_type": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "subject_type", + "number", + "url", + "state", + "can_subscribe" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/subject_subscriptions.go b/pkg/github/subject_subscriptions.go new file mode 100644 index 0000000000..0465f20575 --- /dev/null +++ b/pkg/github/subject_subscriptions.go @@ -0,0 +1,258 @@ +package github + +import ( + "context" + "fmt" + "strings" + + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +type SubjectSubscriptionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + SubjectType string `json:"subjectType"` + Number int32 `json:"number"` +} + +type ManageSubjectSubscriptionInput struct { + SubjectSubscriptionInput + State string `json:"state"` +} + +// SubjectSubscriptionOutput is the authenticated viewer's subscription, not public repository data. +type SubjectSubscriptionOutput struct { + ID string `json:"id"` + SubjectType string `json:"subject_type"` + Number int32 `json:"number"` + URL string `json:"url"` + State *string `json:"state"` + CanSubscribe bool `json:"can_subscribe"` +} + +func subjectSubscriptionInputSchema(write bool) *jsonschema.Schema { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner"}, + "repo": {Type: "string", Description: "Repository name"}, + "subjectType": { + Type: "string", Description: "Type of the subject to subscribe to", + Enum: []any{"issue", "pull_request", "discussion"}, + }, + "number": { + Type: "integer", Description: "Issue, pull request, or discussion number", + Minimum: new(float64(1)), Maximum: new(float64(2147483647)), + }, + }, + Required: []string{"owner", "repo", "subjectType", "number"}, + } + if write { + schema.Properties["state"] = &jsonschema.Schema{ + Type: "string", + Description: "subscribed: receive all updates; unsubscribed: only participating or mentions; ignored: receive no notifications", + Enum: []any{"subscribed", "unsubscribed", "ignored"}, + } + schema.Required = append(schema.Required, "state") + } + return schema +} + +func GetSubjectSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool[SubjectSubscriptionInput, *SubjectSubscriptionOutput]( + ToolsetMetadataNotifications, + mcp.Tool{ + Name: "get_subject_subscription", + Description: t("TOOL_GET_SUBJECT_SUBSCRIPTION_DESCRIPTION", "Get the authenticated user's notification subscription state for a specific issue, pull request, or discussion by repository and number. Does not require an existing notification thread."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_SUBJECT_SUBSCRIPTION_USER_TITLE", "Get subject subscription"), + ReadOnlyHint: true, + }, + InputSchema: subjectSubscriptionInputSchema(false), + }, + scopes.PublicRead(scopes.Repo), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SubjectSubscriptionInput) (*mcp.CallToolResult, *SubjectSubscriptionOutput, error) { + if err := validateSubjectSubscriptionInput(input); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + subject, err := querySubscriptionSubject(ctx, client, input) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get subject subscription", err), nil, nil + } + if subject.ID == nil || subject.ID == "" { + return utils.NewToolResultError("subscription subject not found"), nil, nil + } + return subjectSubscriptionResult(ctx, deps, input, subject) + }, + ) +} + +func ManageSubjectSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool[ManageSubjectSubscriptionInput, *SubjectSubscriptionOutput]( + ToolsetMetadataNotifications, + mcp.Tool{ + Name: "manage_subject_subscription", + Description: t("TOOL_MANAGE_SUBJECT_SUBSCRIPTION_DESCRIPTION", "Subscribe, unsubscribe, or ignore a specific issue, pull request, or discussion for the authenticated user by repository and number. Does not require an existing notification thread and does not watch the whole repository. Use get_subject_subscription to check the current state."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_MANAGE_SUBJECT_SUBSCRIPTION_USER_TITLE", "Manage subject subscription"), + ReadOnlyHint: false, + DestructiveHint: new(true), + IdempotentHint: true, + }, + InputSchema: subjectSubscriptionInputSchema(true), + }, + subjectSubscriptionWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ManageSubjectSubscriptionInput) (*mcp.CallToolResult, *SubjectSubscriptionOutput, error) { + if err := validateSubjectSubscriptionInput(input.SubjectSubscriptionInput); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + switch input.State { + case "subscribed", "unsubscribed", "ignored": + default: + return utils.NewToolResultError("state must be one of: subscribed, unsubscribed, ignored"), nil, nil + } + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + subject, err := querySubscriptionSubject(ctx, client, input.SubjectSubscriptionInput) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get subject subscription", err), nil, nil + } + if subject.ID == nil || subject.ID == "" { + return utils.NewToolResultError("subscription subject not found"), nil, nil + } + desiredState := githubv4.SubscriptionState(strings.ToUpper(input.State)) + if subject.ViewerSubscription != nil && *subject.ViewerSubscription == desiredState { + return subjectSubscriptionResult(ctx, deps, input.SubjectSubscriptionInput, subject) + } + if !subject.ViewerCanSubscribe { + return utils.NewToolResultError("the authenticated user cannot subscribe to this subject"), nil, nil + } + var mutation struct { + UpdateSubscription struct { + Subscribable struct { + ID githubv4.ID + ViewerSubscription *githubv4.SubscriptionState + ViewerCanSubscribe githubv4.Boolean + } + } `graphql:"updateSubscription(input: $input)"` + } + err = client.Mutate(ctx, &mutation, githubv4.UpdateSubscriptionInput{ + SubscribableID: subject.ID, + State: desiredState, + }, nil) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to update subject subscription", err), nil, nil + } + updated := mutation.UpdateSubscription.Subscribable + if updated.ID != subject.ID || updated.ViewerSubscription == nil { + return utils.NewToolResultError("GitHub did not return the requested subject subscription; check its state before retrying"), nil, nil + } + if *updated.ViewerSubscription != desiredState { + return utils.NewToolResultError(fmt.Sprintf("GitHub returned subscription state %s instead of requested %s; use get_subject_subscription to check its state before retrying", strings.ToLower(string(*updated.ViewerSubscription)), input.State)), nil, nil + } + // Subscribable exposes no URL field. Retain the URL from the concrete subject lookup. + subject.ViewerSubscription = updated.ViewerSubscription + subject.ViewerCanSubscribe = updated.ViewerCanSubscribe + return subjectSubscriptionResult(ctx, deps, input.SubjectSubscriptionInput, subject) + }, + ) +} + +func subjectSubscriptionWriteScopeAccess() inventory.ScopeAccess { + access := scopes.RequireAll(scopes.Repo, scopes.Notifications) + // Notifications-only PATs can still target public subjects. OAuth must be + // able to request repository access for the preliminary private subject lookup. + access.Visible = scopes.RequireAll(scopes.Notifications).Visible + access.Challenge = func(_ map[string]any, activeScopes []string) []string { + return append(scopes.ChallengeAll(activeScopes, scopes.Repo), scopes.ChallengeAll(activeScopes, scopes.Notifications)...) + } + return access +} + +func validateSubjectSubscriptionInput(input SubjectSubscriptionInput) error { + if strings.TrimSpace(input.Owner) == "" { + return fmt.Errorf("missing required parameter: owner") + } + if strings.TrimSpace(input.Repo) == "" { + return fmt.Errorf("missing required parameter: repo") + } + if input.Number < 1 { + return fmt.Errorf("number must be between 1 and 2147483647") + } + switch input.SubjectType { + case "issue", "pull_request", "discussion": + return nil + default: + return fmt.Errorf("subjectType must be one of: issue, pull_request, discussion") + } +} + +type subscriptionSubject struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + ViewerSubscription *githubv4.SubscriptionState + ViewerCanSubscribe githubv4.Boolean +} + +func querySubscriptionSubject(ctx context.Context, client *githubv4.Client, input SubjectSubscriptionInput) (subscriptionSubject, error) { + vars := map[string]any{ + "owner": githubv4.String(input.Owner), + "repo": githubv4.String(input.Repo), + "number": githubv4.Int(input.Number), + } + switch input.SubjectType { + case "issue": + var query struct { + Repository struct { + Subject subscriptionSubject `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + err := client.Query(ctx, &query, vars) + return query.Repository.Subject, err + case "pull_request": + var query struct { + Repository struct { + Subject subscriptionSubject `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + err := client.Query(ctx, &query, vars) + return query.Repository.Subject, err + default: // subjectType has already been validated. + var query struct { + Repository struct { + Subject subscriptionSubject `graphql:"discussion(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + err := client.Query(ctx, &query, vars) + return query.Repository.Subject, err + } +} + +func subjectSubscriptionResult(ctx context.Context, deps ToolDependencies, input SubjectSubscriptionInput, subject subscriptionSubject) (*mcp.CallToolResult, *SubjectSubscriptionOutput, error) { + var state *string + if subject.ViewerSubscription != nil { + normalized := strings.ToLower(string(*subject.ViewerSubscription)) + state = &normalized + } + output := &SubjectSubscriptionOutput{ + ID: fmt.Sprint(subject.ID), SubjectType: input.SubjectType, + Number: input.Number, URL: string(subject.URL), + State: state, CanSubscribe: bool(subject.ViewerCanSubscribe), + } + // A viewer's subscription state is private even when its subject is public. + return attachStaticIFCLabel(ctx, deps, MarshalledTextResult(output), ifc.PrivateTrusted()), output, nil +} diff --git a/pkg/github/subject_subscriptions_test.go b/pkg/github/subject_subscriptions_test.go new file mode 100644 index 0000000000..d990509a84 --- /dev/null +++ b/pkg/github/subject_subscriptions_test.go @@ -0,0 +1,369 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func subjectSubscriptionTool(t *testing.T, name string) inventory.ServerTool { + t.Helper() + for _, tool := range AllTools(translations.NullTranslationHelper) { + if tool.Tool.Name == name { + return tool + } + } + t.Fatalf("%s is missing from the tool inventory", name) + return inventory.ServerTool{} +} + +// The fake replaces only GitHub's HTTP boundary; typed decoding and GraphQL generation remain real. +func subscriptionHTTPClient(t *testing.T, field string, currentState any, canSubscribe bool, desiredState string, queryError, mutationError string) (*http.Client, *int) { + t.Helper() + calls := 0 + client := MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/graphql", r.URL.Path) + var body struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.Header().Set("Content-Type", "application/json") + subject := map[string]any{ + "id": "subject-id", "url": "https://github.com/owner/repo/issues/7", + "viewerCanSubscribe": canSubscribe, "viewerSubscription": currentState, + } + if strings.Contains(body.Query, "updateSubscription") { + assert.NotContains(t, body.Query, "url", "Subscribable has no url field in GitHub's GraphQL schema") + assert.Equal(t, 2, calls, "a mutation must follow subject lookup") + assert.Equal(t, map[string]any{"input": map[string]any{ + "subscribableId": "subject-id", "state": desiredState, + }}, body.Variables) + if mutationError != "" { + _ = json.NewEncoder(w).Encode(map[string]any{"errors": []any{map[string]any{"message": mutationError}}}) + return + } + subject["viewerSubscription"] = desiredState + delete(subject, "url") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "updateSubscription": map[string]any{"subscribable": subject}, + }}) + return + } + assert.Equal(t, 1, calls) + assert.Contains(t, strings.ReplaceAll(body.Query, " ", ""), field+"(number:$number)") + assert.Equal(t, map[string]any{"owner": "owner", "repo": "repo", "number": float64(7)}, body.Variables) + if queryError != "" { + _ = json.NewEncoder(w).Encode(map[string]any{"errors": []any{map[string]any{"message": queryError}}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "repository": map[string]any{field: subject}, + }}) + }) + return client, &calls +} + +func Test_SubjectSubscriptions(t *testing.T) { + t.Parallel() + for _, subject := range []struct{ kind, field string }{ + {"issue", "issue"}, {"pull_request", "pullRequest"}, {"discussion", "discussion"}, + } { + for _, action := range []struct { + tool, state, current, desired string + calls int + }{ + {"get_subject_subscription", "", "SUBSCRIBED", "", 1}, + {"manage_subject_subscription", "subscribed", "UNSUBSCRIBED", "SUBSCRIBED", 2}, + {"manage_subject_subscription", "unsubscribed", "SUBSCRIBED", "UNSUBSCRIBED", 2}, + {"manage_subject_subscription", "ignored", "SUBSCRIBED", "IGNORED", 2}, + {"manage_subject_subscription", "subscribed", "SUBSCRIBED", "SUBSCRIBED", 1}, + } { + t.Run(subject.kind+"/"+action.tool+"/"+action.state+"/"+action.current, func(t *testing.T) { + tool := subjectSubscriptionTool(t, action.tool) + httpClient, calls := subscriptionHTTPClient(t, subject.field, action.current, true, action.desired, "", "") + deps := BaseDeps{GQLClient: githubv4.NewClient(httpClient), Obsv: stubExporters()} + args := map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": subject.kind} + if action.state != "" { + args["state"] = action.state + } + request := createMCPRequest(args) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, result) + wantState := action.state + if wantState == "" { + wantState = "subscribed" + } + want, err := json.Marshal(map[string]any{ + "id": "subject-id", "url": "https://github.com/owner/repo/issues/7", + "subject_type": subject.kind, "number": 7, "state": wantState, "can_subscribe": true, + }) + require.NoError(t, err) + assert.JSONEq(t, string(want), getTextResult(t, result).Text) + assert.Equal(t, action.calls, *calls) + }) + } + } +} + +func Test_SubjectSubscriptions_InvalidArguments(t *testing.T) { + t.Parallel() + for _, name := range []string{"get_subject_subscription", "manage_subject_subscription"} { + t.Run(name, func(t *testing.T) { + tool := subjectSubscriptionTool(t, name) + cases := []struct { + field string + value any + }{ + {"owner", ""}, {"repo", ""}, {"number", 0}, {"number", -1}, + {"number", 1.5}, {"number", 2147483648}, {"subjectType", "repository"}, + {"subjectType", ""}, {"owner", 9}, + } + if name == "manage_subject_subscription" { + cases = append(cases, struct { + field string + value any + }{"state", "watch"}) + } + for _, tc := range cases { + args := map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue"} + if name == "manage_subject_subscription" { + args["state"] = "subscribed" + } + args[tc.field] = tc.value + deps := stubDeps{ + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + t.Fatal("invalid arguments must not obtain a GitHub client") + return nil, nil + }, obsv: stubExporters(), + } + req := createMCPRequest(args) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError, args) + assert.Contains(t, getErrorResult(t, result).Text, tc.field) + } + for _, field := range []string{"owner", "repo", "number", "subjectType", "state"} { + if field == "state" && name == "get_subject_subscription" { + continue + } + args := map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue", "state": "subscribed"} + delete(args, field) + deps := stubDeps{gqlClientFn: func(context.Context) (*githubv4.Client, error) { + t.Fatal("missing argument reached GitHub") + return nil, nil + }, obsv: stubExporters()} + req := createMCPRequest(args) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, field) + } + }) + } +} + +func Test_SubjectSubscriptions_Errors(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name, queryError, mutationError string + canSubscribe bool + calls int + want string + }{ + {"query denied", "Resource not accessible", "", true, 1, "Resource not accessible"}, + {"cannot subscribe", "", "", false, 1, "subscribe"}, + {"mutation denied", "", "Permission denied", true, 2, "Permission denied"}, + } { + t.Run(tc.name, func(t *testing.T) { + tool := subjectSubscriptionTool(t, "manage_subject_subscription") + client, calls := subscriptionHTTPClient(t, "issue", "UNSUBSCRIBED", tc.canSubscribe, "SUBSCRIBED", tc.queryError, tc.mutationError) + deps := BaseDeps{GQLClient: githubv4.NewClient(client), Obsv: stubExporters()} + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue", "state": "subscribed"}) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.want) + assert.Equal(t, tc.calls, *calls) + assert.Nil(t, result.StructuredContent) + }) + } + for _, name := range []string{"get_subject_subscription", "manage_subject_subscription"} { + t.Run(name+"/client error", func(t *testing.T) { + tool := subjectSubscriptionTool(t, name) + deps := stubDeps{gqlClientFn: stubGQLClientFnErr("client unavailable"), obsv: stubExporters()} + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue", "state": "subscribed"}) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "client unavailable") + }) + } +} + +func Test_SubjectSubscriptions_Protocols(t *testing.T) { + t.Parallel() + for _, name := range []string{"get_subject_subscription", "manage_subject_subscription"} { + tool := subjectSubscriptionTool(t, name) + require.Equal(t, ToolsetMetadataNotifications.ID, tool.Toolset.ID) + assert.Equal(t, name == "get_subject_subscription", tool.IsReadOnly()) + require.NoError(t, toolsnaps.Test(name, tool.Tool)) + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(name+"/"+protocol, func(t *testing.T) { + client, _ := subscriptionHTTPClient(t, "issue", "SUBSCRIBED", true, "SUBSCRIBED", "", "") + deps := BaseDeps{GQLClient: githubv4.NewClient(client), Obsv: stubExporters()} + session := connectTypedSearchClient(t, tool, deps, protocol) + listed, err := session.ListTools(t.Context(), nil) + require.NoError(t, err) + require.Len(t, listed.Tools, 1) + modern := protocol == inventory.ProtocolVersionMultiRoundTrip + assert.Equal(t, modern, listed.Tools[0].OutputSchema != nil) + if modern { + require.NoError(t, toolsnaps.Test(name+"_output", mcp.Tool{Name: name, OutputSchema: listed.Tools[0].OutputSchema})) + } + result, err := session.CallTool(t.Context(), &mcp.CallToolParams{Name: name, Arguments: map[string]any{ + "owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue", "state": "subscribed", + }}) + require.NoError(t, err) + require.False(t, result.IsError, result) + text := getTextResult(t, result).Text + assert.JSONEq(t, `{"id":"subject-id","url":"https://github.com/owner/repo/issues/7","subject_type":"issue","number":7,"state":"subscribed","can_subscribe":true}`, text) + if modern { + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, text, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } + } +} + +func Test_SubjectSubscriptions_PrivateIFC(t *testing.T) { + t.Parallel() + for _, name := range []string{"get_subject_subscription", "manage_subject_subscription"} { + t.Run(name, func(t *testing.T) { + tool := subjectSubscriptionTool(t, name) + client, _ := subscriptionHTTPClient(t, "issue", "SUBSCRIBED", true, "SUBSCRIBED", "", "") + deps := NewBaseDeps(nil, githubv4.NewClient(client), nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, + func(context.Context, string) (bool, error) { return true, nil }, stubExporters()) + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue", "state": "subscribed"}) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.False(t, result.IsError, result) + label, err := json.Marshal(result.Meta["ifc"]) + require.NoError(t, err) + assert.JSONEq(t, `{"integrity":"trusted","confidentiality":"private"}`, string(label)) + }) + } +} + +func Test_SubjectSubscriptions_MissingSubject(t *testing.T) { + t.Parallel() + for _, name := range []string{"get_subject_subscription", "manage_subject_subscription"} { + for _, response := range []string{ + `{"data":{"repository":null}}`, + `{"data":{"repository":{"issue":null}}}`, + } { + t.Run(name+"/"+response, func(t *testing.T) { + tool := subjectSubscriptionTool(t, name) + calls := 0 + client := MockHTTPClientWithHandler(func(w http.ResponseWriter, _ *http.Request) { + calls++ + require.Equal(t, 1, calls, "a missing subject must never be mutated") + _, _ = w.Write([]byte(response)) + }) + deps := BaseDeps{GQLClient: githubv4.NewClient(client), Obsv: stubExporters()} + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue", "state": "subscribed"}) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "not found") + assert.Equal(t, 1, calls) + }) + } + } +} + +func Test_SubjectSubscriptions_InvalidMutationResponse(t *testing.T) { + t.Parallel() + for _, response := range []string{ + `{"data":{"updateSubscription":null}}`, + `{"data":{"updateSubscription":{"subscribable":null}}}`, + `{"data":{"updateSubscription":{"subscribable":{"id":"wrong-id","viewerSubscription":"SUBSCRIBED","viewerCanSubscribe":true}}}}`, + `{"data":{"updateSubscription":{"subscribable":{"id":"subject-id","viewerSubscription":null,"viewerCanSubscribe":true}}}}`, + `{"data":{"updateSubscription":{"subscribable":{"id":"subject-id","viewerSubscription":"UNSUBSCRIBED","viewerCanSubscribe":true}}}}`, + } { + t.Run(response, func(t *testing.T) { + tool := subjectSubscriptionTool(t, "manage_subject_subscription") + calls := 0 + client := MockHTTPClientWithHandler(func(w http.ResponseWriter, _ *http.Request) { + calls++ + if calls == 1 { + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"id":"subject-id","url":"https://github.com/owner/repo/issues/7","viewerSubscription":"UNSUBSCRIBED","viewerCanSubscribe":true}}}}`)) + return + } + _, _ = w.Write([]byte(response)) + }) + deps := BaseDeps{GQLClient: githubv4.NewClient(client), Obsv: stubExporters()} + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue", "state": "subscribed"}) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError, "incomplete or mismatched mutation data must not report success") + assert.Nil(t, result.StructuredContent) + if strings.Contains(response, `"viewerSubscription":"UNSUBSCRIBED"`) { + text := getErrorResult(t, result).Text + assert.Contains(t, text, "unsubscribed") + assert.Contains(t, text, "subscribed") + } + assert.Equal(t, 2, calls) + }) + } +} + +func Test_SubjectSubscriptions_NullState(t *testing.T) { + t.Parallel() + tool := subjectSubscriptionTool(t, "get_subject_subscription") + client, calls := subscriptionHTTPClient(t, "issue", nil, false, "", "", "") + deps := BaseDeps{GQLClient: githubv4.NewClient(client), Obsv: stubExporters()} + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo", "number": 7, "subjectType": "issue"}) + result, err := tool.Handler(deps)(ContextWithDeps(t.Context(), deps), &req) + require.NoError(t, err) + require.False(t, result.IsError, result) + assert.JSONEq(t, `{"id":"subject-id","url":"https://github.com/owner/repo/issues/7","subject_type":"issue","number":7,"state":null,"can_subscribe":false}`, getTextResult(t, result).Text) + assert.Equal(t, 1, *calls) +} + +func Test_SubjectSubscriptions_NotificationScope(t *testing.T) { + t.Parallel() + tool := subjectSubscriptionTool(t, "manage_subject_subscription") + for _, tc := range []struct { + scopes []string + visible bool + }{ + {nil, false}, + {[]string{"repo"}, false}, + {[]string{"public_repo"}, false}, + {[]string{"notifications"}, true}, + {[]string{"repo", "notifications"}, true}, + } { + assert.Equal(t, tc.visible, tool.ScopeAccess.Visible(tc.scopes), tc.scopes) + } + assert.Equal(t, []string{"notifications"}, tool.ScopeAccess.Challenge(nil, []string{"repo"})) + assert.Equal(t, []string{"repo"}, tool.ScopeAccess.Challenge(nil, []string{"notifications"}), "private subject lookup needs repository access") + assert.Empty(t, tool.ScopeAccess.Challenge(nil, []string{"repo", "notifications"})) + assert.ElementsMatch(t, []string{"repo", "notifications"}, tool.ScopeAccess.Scopes, "maximum scopes must include the lookup and mutation") +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 53503f68fe..143e4de331 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -319,6 +319,8 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent MarkAllNotificationsRead(t), ManageNotificationSubscription(t), ManageRepositoryNotificationSubscription(t), + GetSubjectSubscription(t), + ManageSubjectSubscription(t), // Discussion tools ListDiscussions(t),