diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index 0672e0c3f..fb2a9e7b3 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -1,35 +1,34 @@ { "annotations": { - "title": "Add comment to issue", - "readOnlyHint": false + "title": "Add comment to issue" }, "description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.", "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "issue_number", + "body" + ], "properties": { "body": { - "description": "Comment content", - "type": "string" + "type": "string", + "description": "Comment content" }, "issue_number": { - "description": "Issue number to comment on", - "type": "number" + "type": "number", + "description": "Issue number to comment on" }, "owner": { - "description": "Repository owner", - "type": "string" + "type": "string", + "description": "Repository owner" }, "repo": { - "description": "Repository name", - "type": "string" + "type": "string", + "description": "Repository name" } - }, - "required": [ - "owner", - "repo", - "issue_number", - "body" - ], - "type": "object" + } }, "name": "add_issue_comment" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 2d61ccfbd..e250ca9c1 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -1,31 +1,30 @@ { "annotations": { - "title": "Assign Copilot to issue", - "readOnlyHint": false, - "idempotentHint": true + "idempotentHint": true, + "title": "Assign Copilot to issue" }, "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n", "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo", + "issueNumber" + ], "properties": { "issueNumber": { - "description": "Issue number", - "type": "number" + "type": "number", + "description": "Issue number" }, "owner": { - "description": "Repository owner", - "type": "string" + "type": "string", + "description": "Repository owner" }, "repo": { - "description": "Repository name", - "type": "string" + "type": "string", + "description": "Repository name" } - }, - "required": [ - "owner", - "repo", - "issueNumber" - ], - "type": "object" + } }, "name": "assign_copilot_to_issue" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index 9e9462df6..c6a9e7306 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -1,52 +1,52 @@ { "annotations": { - "title": "Get issue details", - "readOnlyHint": true + "readOnlyHint": true, + "title": "Get issue details" }, "description": "Get information about a specific issue in a GitHub repository.", "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo", + "issue_number" + ], "properties": { "issue_number": { - "description": "The number of the issue", - "type": "number" + "type": "number", + "description": "The number of the issue" }, "method": { - "description": "The read operation to perform on a single issue. \nOptions are: \n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n", + "type": "string", + "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues of the issue.\n4. get_labels - Get labels assigned to the issue.\n", "enum": [ "get", "get_comments", "get_sub_issues", "get_labels" - ], - "type": "string" + ] }, "owner": { - "description": "The owner of the repository", - "type": "string" + "type": "string", + "description": "The owner of the repository" }, "page": { + "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" + "minimum": 1 }, "perPage": { + "type": "number", "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, "minimum": 1, - "type": "number" + "maximum": 100 }, "repo": { - "description": "The name of the repository", - "type": "string" + "type": "string", + "description": "The name of the repository" } - }, - "required": [ - "method", - "owner", - "repo", - "issue_number" - ], - "type": "object" + } }, "name": "issue_read" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 3f2a37084..8c6634a02 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -1,89 +1,88 @@ { "annotations": { - "title": "Create or update issue.", - "readOnlyHint": false + "title": "Create or update issue." }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo" + ], "properties": { "assignees": { + "type": "array", "description": "Usernames to assign to this issue", "items": { "type": "string" - }, - "type": "array" + } }, "body": { - "description": "Issue body content", - "type": "string" + "type": "string", + "description": "Issue body content" }, "duplicate_of": { - "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", - "type": "number" + "type": "number", + "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'." }, "issue_number": { - "description": "Issue number to update", - "type": "number" + "type": "number", + "description": "Issue number to update" }, "labels": { + "type": "array", "description": "Labels to apply to this issue", "items": { "type": "string" - }, - "type": "array" + } }, "method": { - "description": "Write operation to perform on a single issue.\nOptions are: \n- 'create' - creates a new issue. \n- 'update' - updates an existing issue.\n", + "type": "string", + "description": "Write operation to perform on a single issue.\nOptions are:\n- 'create' - creates a new issue.\n- 'update' - updates an existing issue.\n", "enum": [ "create", "update" - ], - "type": "string" + ] }, "milestone": { - "description": "Milestone number", - "type": "number" + "type": "number", + "description": "Milestone number" }, "owner": { - "description": "Repository owner", - "type": "string" + "type": "string", + "description": "Repository owner" }, "repo": { - "description": "Repository name", - "type": "string" + "type": "string", + "description": "Repository name" }, "state": { + "type": "string", "description": "New state", "enum": [ "open", "closed" - ], - "type": "string" + ] }, "state_reason": { + "type": "string", "description": "Reason for the state change. Ignored unless state is changed.", "enum": [ "completed", "not_planned", "duplicate" - ], - "type": "string" + ] }, "title": { - "description": "Issue title", - "type": "string" + "type": "string", + "description": "Issue title" }, "type": { - "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", - "type": "string" + "type": "string", + "description": "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter." } - }, - "required": [ - "method", - "owner", - "repo" - ], - "type": "object" + } }, "name": "issue_write" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap index 93c3e51d9..b17dcc54f 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -1,20 +1,20 @@ { "annotations": { - "title": "List available issue types", - "readOnlyHint": true + "readOnlyHint": true, + "title": "List available issue types" }, "description": "List supported issue types for repository owner (organization).", "inputSchema": { - "properties": { - "owner": { - "description": "The organization owner of the repository", - "type": "string" - } - }, + "type": "object", "required": [ "owner" ], - "type": "object" + "properties": { + "owner": { + "type": "string", + "description": "The organization owner of the repository" + } + } }, "name": "list_issue_types" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 5475988c2..9d6b55586 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -1,71 +1,71 @@ { "annotations": { - "title": "List issues", - "readOnlyHint": true + "readOnlyHint": true, + "title": "List issues" }, "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", "inputSchema": { + "type": "object", + "required": [ + "owner", + "repo" + ], "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", - "type": "string" + "type": "string", + "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs." }, "direction": { + "type": "string", "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", "enum": [ "ASC", "DESC" - ], - "type": "string" + ] }, "labels": { + "type": "array", "description": "Filter by labels", "items": { "type": "string" - }, - "type": "array" + } }, "orderBy": { + "type": "string", "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", "enum": [ "CREATED_AT", "UPDATED_AT", "COMMENTS" - ], - "type": "string" + ] }, "owner": { - "description": "Repository owner", - "type": "string" + "type": "string", + "description": "Repository owner" }, "perPage": { + "type": "number", "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, "minimum": 1, - "type": "number" + "maximum": 100 }, "repo": { - "description": "Repository name", - "type": "string" + "type": "string", + "description": "Repository name" }, "since": { - "description": "Filter by date (ISO 8601 timestamp)", - "type": "string" + "type": "string", + "description": "Filter by date (ISO 8601 timestamp)" }, "state": { + "type": "string", "description": "Filter by state, by default both open and closed issues are returned when not provided", "enum": [ "OPEN", "CLOSED" - ], - "type": "string" + ] } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" + } }, "name": "list_issues" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap index bf1982411..f76a715fb 100644 --- a/pkg/github/__toolsnaps__/search_issues.snap +++ b/pkg/github/__toolsnaps__/search_issues.snap @@ -1,43 +1,48 @@ { "annotations": { - "title": "Search issues", - "readOnlyHint": true + "readOnlyHint": true, + "title": "Search issues" }, "description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue", "inputSchema": { + "type": "object", + "required": [ + "query" + ], "properties": { "order": { + "type": "string", "description": "Sort order", "enum": [ "asc", "desc" - ], - "type": "string" + ] }, "owner": { - "description": "Optional repository owner. If provided with repo, only issues for this repository are listed.", - "type": "string" + "type": "string", + "description": "Optional repository owner. If provided with repo, only issues for this repository are listed." }, "page": { + "type": "number", "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" + "minimum": 1 }, "perPage": { + "type": "number", "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, "minimum": 1, - "type": "number" + "maximum": 100 }, "query": { - "description": "Search query using GitHub issues search syntax", - "type": "string" + "type": "string", + "description": "Search query using GitHub issues search syntax" }, "repo": { - "description": "Optional repository name. If provided with owner, only issues for this repository are listed.", - "type": "string" + "type": "string", + "description": "Optional repository name. If provided with owner, only issues for this repository are listed." }, "sort": { + "type": "string", "description": "Sort field by number of matches of categories, defaults to best match", "enum": [ "comments", @@ -51,14 +56,9 @@ "interactions", "created", "updated" - ], - "type": "string" + ] } - }, - "required": [ - "query" - ], - "type": "object" + } }, "name": "search_issues" } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap index d79e723f4..1c721a2bb 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -1,52 +1,51 @@ { "annotations": { - "title": "Change sub-issue", - "readOnlyHint": false + "title": "Change sub-issue" }, "description": "Add a sub-issue to a parent issue in a GitHub repository.", "inputSchema": { + "type": "object", + "required": [ + "method", + "owner", + "repo", + "issue_number", + "sub_issue_id" + ], "properties": { "after_id": { - "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)", - "type": "number" + "type": "number", + "description": "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)" }, "before_id": { - "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)", - "type": "number" + "type": "number", + "description": "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)" }, "issue_number": { - "description": "The number of the parent issue", - "type": "number" + "type": "number", + "description": "The number of the parent issue" }, "method": { - "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t", - "type": "string" + "type": "string", + "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t" }, "owner": { - "description": "Repository owner", - "type": "string" + "type": "string", + "description": "Repository owner" }, "replace_parent": { - "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only.", - "type": "boolean" + "type": "boolean", + "description": "When true, replaces the sub-issue's current parent issue. Use with 'add' method only." }, "repo": { - "description": "Repository name", - "type": "string" + "type": "string", + "description": "Repository name" }, "sub_issue_id": { - "description": "The ID of the sub-issue to add. ID is not the same as issue number", - "type": "number" + "type": "number", + "description": "The ID of the sub-issue to add. ID is not the same as issue number" } - }, - "required": [ - "method", - "owner", - "repo", - "issue_number", - "sub_issue_id" - ], - "type": "object" + } }, "name": "sub_issue_write" } \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index a37583a18..54397750e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1,5 +1,3 @@ -//go:build ignore - package github import ( @@ -15,13 +13,19 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v79/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) +const ( + // DefaultGraphQLPageSize is the default page size for GraphQL queries + DefaultGraphQLPageSize = 30 +) + // CloseIssueInput represents the input for closing an issue via the GraphQL API. // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. type CloseIssueInput struct { @@ -229,85 +233,97 @@ func fragmentToIssue(fragment IssueFragment) *github.Issue { } } -// GetIssue creates a tool to get details of a specific issue in a GitHub repository. -func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("issue_read", - mcp.WithDescription(t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("method", - mcp.Required(), - mcp.Description(`The read operation to perform on a single issue. +// IssueRead creates a tool to get details of a specific issue in a GitHub repository. +func IssueRead(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc, flags FeatureFlags) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `The read operation to perform on a single issue. Options are: 1. get - Get details of a specific issue. 2. get_comments - Get issue comments. 3. get_sub_issues - Get sub-issues of the issue. 4. get_labels - Get labels assigned to the issue. -`), - - mcp.Enum("get", "get_comments", "get_sub_issues", "get_labels"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The owner of the repository"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("The name of the repository"), - ), - mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("The number of the issue"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - method, err := RequiredParam[string](request, "method") +`, + Enum: []any{"get", "get_comments", "get_sub_issues", "get_labels"}, + }, + "owner": { + Type: "string", + Description: "The owner of the repository", + }, + "repo": { + Type: "string", + Description: "The name of the repository", + }, + "issue_number": { + Type: "number", + Description: "The number of the issue", + }, + }, + Required: []string{"method", "owner", "repo", "issue_number"}, + } + WithPagination(schema) + + return mcp.Tool{ + Name: "issue_read", + Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - owner, err := RequiredParam[string](request, "owner") + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := RequiredParam[string](request, "repo") + repo, err := RequiredParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - issueNumber, err := RequiredInt(request, "issue_number") + issueNumber, err := RequiredInt(args, "issue_number") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - pagination, err := OptionalPaginationParams(request) + pagination, err := OptionalPaginationParams(args) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } gqlClient, err := getGQLClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub graphql client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil } switch method { case "get": - return GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags) + result, err := GetIssue(ctx, client, gqlClient, owner, repo, issueNumber, flags) + return result, nil, err case "get_comments": - return GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags) + result, err := GetIssueComments(ctx, client, owner, repo, issueNumber, pagination, flags) + return result, nil, err case "get_sub_issues": - return GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags) + result, err := GetSubIssues(ctx, client, owner, repo, issueNumber, pagination, flags) + return result, nil, err case "get_labels": - return GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags) + result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber, flags) + return result, nil, err default: - return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } } } @@ -324,17 +340,17 @@ func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Cl if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil } if flags.LockdownMode { if issue.User != nil { shouldRemoveContent, err := lockdown.ShouldRemoveContent(ctx, gqlClient, *issue.User.Login, owner, repo) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil } if shouldRemoveContent { - return mcp.NewToolResultError("access to issue details is restricted by lockdown mode"), nil + return utils.NewToolResultError("access to issue details is restricted by lockdown mode"), nil } } } @@ -354,7 +370,7 @@ func GetIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Cl return nil, fmt.Errorf("failed to marshal issue: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } func GetIssueComments(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams, _ FeatureFlags) (*mcp.CallToolResult, error) { @@ -376,7 +392,7 @@ func GetIssueComments(ctx context.Context, client *github.Client, owner string, if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get issue comments: %s", string(body))), nil } r, err := json.Marshal(comments) @@ -384,7 +400,7 @@ func GetIssueComments(ctx context.Context, client *github.Client, owner string, return nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, pagination PaginationParams, _ FeatureFlags) (*mcp.CallToolResult, error) { @@ -411,7 +427,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to list sub-issues: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to list sub-issues: %s", string(body))), nil } r, err := json.Marshal(subIssues) @@ -419,7 +435,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, owner string, repo return nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int, _ FeatureFlags) (*mcp.CallToolResult, error) { @@ -471,98 +487,111 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, return nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(out)), nil + return utils.NewToolResultText(string(out)), nil } // ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. -func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - - return mcp.NewTool("list_issue_types", - mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "list_issue_types", + Description: t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization)."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("The organization owner of the repository"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The organization owner of the repository", + }, + }, + Required: []string{"owner"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) if err != nil { - return nil, fmt.Errorf("failed to list issue types: %w", err) + return utils.NewToolResultErrorFromErr("failed to list issue types", err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } - return mcp.NewToolResultError(fmt.Sprintf("failed to list issue types: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to list issue types: %s", string(body))), nil, nil } r, err := json.Marshal(issueTypes) if err != nil { - return nil, fmt.Errorf("failed to marshal issue types: %w", err) + return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil, nil } } // AddIssueComment creates a tool to add a comment to an issue. -func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("add_issue_comment", - mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "add_issue_comment", + Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("Issue number to comment on"), - ), - mcp.WithString("body", - mcp.Required(), - mcp.Description("Comment content"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number to comment on", + }, + "body": { + Type: "string", + Description: "Comment content", + }, + }, + Required: []string{"owner", "repo", "issue_number", "body"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := RequiredParam[string](request, "repo") + repo, err := RequiredParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - issueNumber, err := RequiredInt(request, "issue_number") + issueNumber, err := RequiredInt(args, "issue_number") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - body, err := RequiredParam[string](request, "body") + body, err := RequiredParam[string](args, "body") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } comment := &github.IssueComment{ @@ -571,125 +600,138 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) if err != nil { - return nil, fmt.Errorf("failed to create comment: %w", err) + return utils.NewToolResultErrorFromErr("failed to create comment", err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } - return mcp.NewToolResultError(fmt.Sprintf("failed to create comment: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to create comment: %s", string(body))), nil, nil } r, err := json.Marshal(createdComment) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil, nil } } // SubIssueWrite creates a tool to add a sub-issue to a parent issue. -func SubIssueWrite(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("sub_issue_write", - mcp.WithDescription(t("TOOL_SUB_ISSUE_WRITE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func SubIssueWrite(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "sub_issue_write", + Description: t("TOOL_SUB_ISSUE_WRITE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SUB_ISSUE_WRITE_USER_TITLE", "Change sub-issue"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("method", - mcp.Required(), - mcp.Description(`The action to perform on a single sub-issue + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `The action to perform on a single sub-issue Options are: - 'add' - add a sub-issue to a parent issue in a GitHub repository. - 'remove' - remove a sub-issue from a parent issue in a GitHub repository. - 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position. - `), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issue_number", - mcp.Required(), - mcp.Description("The number of the parent issue"), - ), - mcp.WithNumber("sub_issue_id", - mcp.Required(), - mcp.Description("The ID of the sub-issue to add. ID is not the same as issue number"), - ), - mcp.WithBoolean("replace_parent", - mcp.Description("When true, replaces the sub-issue's current parent issue. Use with 'add' method only."), - ), - mcp.WithNumber("after_id", - mcp.Description("The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)"), - ), - mcp.WithNumber("before_id", - mcp.Description("The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - method, err := RequiredParam[string](request, "method") + `, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The number of the parent issue", + }, + "sub_issue_id": { + Type: "number", + Description: "The ID of the sub-issue to add. ID is not the same as issue number", + }, + "replace_parent": { + Type: "boolean", + Description: "When true, replaces the sub-issue's current parent issue. Use with 'add' method only.", + }, + "after_id": { + Type: "number", + Description: "The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified)", + }, + "before_id": { + Type: "number", + Description: "The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified)", + }, + }, + Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - owner, err := RequiredParam[string](request, "owner") + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := RequiredParam[string](request, "repo") + repo, err := RequiredParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - issueNumber, err := RequiredInt(request, "issue_number") + issueNumber, err := RequiredInt(args, "issue_number") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - subIssueID, err := RequiredInt(request, "sub_issue_id") + subIssueID, err := RequiredInt(args, "sub_issue_id") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - replaceParent, err := OptionalParam[bool](request, "replace_parent") + replaceParent, err := OptionalParam[bool](args, "replace_parent") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - afterID, err := OptionalIntParam(request, "after_id") + afterID, err := OptionalIntParam(args, "after_id") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - beforeID, err := OptionalIntParam(request, "before_id") + beforeID, err := OptionalIntParam(args, "before_id") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } switch strings.ToLower(method) { case "add": - return AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) + result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) + return result, nil, err case "remove": // Call the remove sub-issue function - return RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) + result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) + return result, nil, err case "reprioritize": // Call the reprioritize sub-issue function - return ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) + result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) + return result, nil, err default: - return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } } } @@ -716,7 +758,7 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to add sub-issue: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to add sub-issue: %s", string(body))), nil } r, err := json.Marshal(subIssue) @@ -724,7 +766,7 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo return nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } @@ -748,7 +790,7 @@ func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, re if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to remove sub-issue: %s", string(body))), nil } r, err := json.Marshal(subIssue) @@ -756,16 +798,16 @@ func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, re return nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, afterID int, beforeID int) (*mcp.CallToolResult, error) { // Validate that either after_id or before_id is specified, but not both if afterID == 0 && beforeID == 0 { - return mcp.NewToolResultError("either after_id or before_id must be specified"), nil + return utils.NewToolResultError("either after_id or before_id must be specified"), nil } if afterID != 0 && beforeID != 0 { - return mcp.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil + return utils.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil } subIssueRequest := github.SubIssueRequest{ @@ -797,7 +839,7 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to reprioritize sub-issue: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to reprioritize sub-issue: %s", string(body))), nil } r, err := json.Marshal(subIssue) @@ -805,30 +847,30 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri return nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } // SearchIssues creates a tool to search for issues. -func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("search_issues", - mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query using GitHub issues search syntax"), - ), - mcp.WithString("owner", - mcp.Description("Optional repository owner. If provided with repo, only issues for this repository are listed."), - ), - mcp.WithString("repo", - mcp.Description("Optional repository name. If provided with owner, only issues for this repository are listed."), - ), - mcp.WithString("sort", - mcp.Description("Sort field by number of matches of categories, defaults to best match"), - mcp.Enum( +func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": { + Type: "string", + Description: "Search query using GitHub issues search syntax", + }, + "owner": { + Type: "string", + Description: "Optional repository owner. If provided with repo, only issues for this repository are listed.", + }, + "repo": { + Type: "string", + Description: "Optional repository name. If provided with owner, only issues for this repository are listed.", + }, + "sort": { + Type: "string", + Description: "Sort field by number of matches of categories, defaults to best match", + Enum: []any{ "comments", "reactions", "reactions-+1", @@ -840,128 +882,155 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) ( "interactions", "created", "updated", - ), - ), - mcp.WithString("order", - mcp.Description("Sort order"), - mcp.Enum("asc", "desc"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return searchHandler(ctx, getClient, request, "issue", "failed to search issues") + }, + }, + "order": { + Type: "string", + Description: "Sort order", + Enum: []any{"asc", "desc"}, + }, + }, + Required: []string{"query"}, + } + WithPagination(schema) + + return mcp.Tool{ + Name: "search_issues", + Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + result, err := searchHandler(ctx, getClient, args, "issue", "failed to search issues") + return result, nil, err } } -// CreateIssue creates a tool to create a new issue in a GitHub repository. -func IssueWrite(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("issue_write", - mcp.WithDescription(t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. +func IssueWrite(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + return mcp.Tool{ + Name: "issue_write", + Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue."), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("method", - mcp.Required(), - mcp.Description(`Write operation to perform on a single issue. + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a single issue. Options are: - 'create' - creates a new issue. - 'update' - updates an existing issue. -`), - mcp.Enum("create", "update"), - ), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issue_number", - mcp.Description("Issue number to update"), - ), - mcp.WithString("title", - mcp.Description("Issue title"), - ), - mcp.WithString("body", - mcp.Description("Issue body content"), - ), - mcp.WithArray("assignees", - mcp.Description("Usernames to assign to this issue"), - mcp.Items( - map[string]any{ - "type": "string", +`, + Enum: []any{"create", "update"}, + }, + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "Issue number to update", + }, + "title": { + Type: "string", + Description: "Issue title", + }, + "body": { + Type: "string", + Description: "Issue body content", + }, + "assignees": { + Type: "array", + Description: "Usernames to assign to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "labels": { + Type: "array", + Description: "Labels to apply to this issue", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "milestone": { + Type: "number", + Description: "Milestone number", }, - ), - ), - mcp.WithArray("labels", - mcp.Description("Labels to apply to this issue"), - mcp.Items( - map[string]any{ - "type": "string", + "type": { + Type: "string", + Description: "Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter.", }, - ), - ), - mcp.WithNumber("milestone", - mcp.Description("Milestone number"), - ), - mcp.WithString("type", - mcp.Description("Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter."), - ), - mcp.WithString("state", - mcp.Description("New state"), - mcp.Enum("open", "closed"), - ), - mcp.WithString("state_reason", - mcp.Description("Reason for the state change. Ignored unless state is changed."), - mcp.Enum("completed", "not_planned", "duplicate"), - ), - mcp.WithNumber("duplicate_of", - mcp.Description("Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'."), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - method, err := RequiredParam[string](request, "method") + "state": { + Type: "string", + Description: "New state", + Enum: []any{"open", "closed"}, + }, + "state_reason": { + Type: "string", + Description: "Reason for the state change. Ignored unless state is changed.", + Enum: []any{"completed", "not_planned", "duplicate"}, + }, + "duplicate_of": { + Type: "number", + Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", + }, + }, + Required: []string{"method", "owner", "repo"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - owner, err := RequiredParam[string](request, "owner") + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := RequiredParam[string](request, "repo") + repo, err := RequiredParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - title, err := OptionalParam[string](request, "title") + title, err := OptionalParam[string](args, "title") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Optional parameters - body, err := OptionalParam[string](request, "body") + body, err := OptionalParam[string](args, "body") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Get assignees - assignees, err := OptionalStringArrayParam(request, "assignees") + assignees, err := OptionalStringArrayParam(args, "assignees") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Get labels - labels, err := OptionalStringArrayParam(request, "labels") + labels, err := OptionalStringArrayParam(args, "labels") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Get optional milestone - milestone, err := OptionalIntParam(request, "milestone") + milestone, err := OptionalIntParam(args, "milestone") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } var milestoneNum int @@ -970,58 +1039,60 @@ Options are: } // Get optional type - issueType, err := OptionalParam[string](request, "type") + issueType, err := OptionalParam[string](args, "type") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Handle state, state_reason and duplicateOf parameters - state, err := OptionalParam[string](request, "state") + state, err := OptionalParam[string](args, "state") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - stateReason, err := OptionalParam[string](request, "state_reason") + stateReason, err := OptionalParam[string](args, "state_reason") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - duplicateOf, err := OptionalIntParam(request, "duplicate_of") + duplicateOf, err := OptionalIntParam(args, "duplicate_of") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } if duplicateOf != 0 && stateReason != "duplicate" { - return mcp.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil + return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil } client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } gqlClient, err := getGQLClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GraphQL client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil } switch method { case "create": - return CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType) + result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType) + return result, nil, err case "update": - issueNumber, err := RequiredInt(request, "issue_number") + issueNumber, err := RequiredInt(args, "issue_number") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - return UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf) + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf) + return result, nil, err default: - return mcp.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil + return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil } } } func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string) (*mcp.CallToolResult, error) { if title == "" { - return mcp.NewToolResultError("missing required parameter: title"), nil + return utils.NewToolResultError("missing required parameter: title"), nil } // Create the issue request @@ -1042,16 +1113,16 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) if err != nil { - return nil, fmt.Errorf("failed to create issue: %w", err) + return utils.NewToolResultErrorFromErr("failed to create issue", err), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil } - return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil } // Return minimal response with just essential information @@ -1062,10 +1133,10 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo r, err := json.Marshal(minimalResponse) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { @@ -1112,14 +1183,14 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil } // Use GraphQL API for state updates if state != "" { // Mandate specifying duplicateOf when trying to close as duplicate if state == "closed" && stateReason == "duplicate" && duplicateOf == 0 { - return mcp.NewToolResultError("duplicate_of must be provided when state_reason is 'duplicate'"), nil + return utils.NewToolResultError("duplicate_of must be provided when state_reason is 'duplicate'"), nil } // Get target issue ID (and duplicate issue ID if needed) @@ -1190,64 +1261,76 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 return nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } // ListIssues creates a tool to list and filter repository issues -func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_issues", - mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ +func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "state": { + Type: "string", + Description: "Filter by state, by default both open and closed issues are returned when not provided", + Enum: []any{"OPEN", "CLOSED"}, + }, + "labels": { + Type: "array", + Description: "Filter by labels", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "orderBy": { + Type: "string", + Description: "Order issues by field. If provided, the 'direction' also needs to be provided.", + Enum: []any{"CREATED_AT", "UPDATED_AT", "COMMENTS"}, + }, + "direction": { + Type: "string", + Description: "Order direction. If provided, the 'orderBy' also needs to be provided.", + Enum: []any{"ASC", "DESC"}, + }, + "since": { + Type: "string", + Description: "Filter by date (ISO 8601 timestamp)", + }, + }, + Required: []string{"owner", "repo"}, + } + WithCursorPagination(schema) + + return mcp.Tool{ + Name: "list_issues", + Description: t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithString("state", - mcp.Description("Filter by state, by default both open and closed issues are returned when not provided"), - mcp.Enum("OPEN", "CLOSED"), - ), - mcp.WithArray("labels", - mcp.Description("Filter by labels"), - mcp.Items( - map[string]interface{}{ - "type": "string", - }, - ), - ), - mcp.WithString("orderBy", - mcp.Description("Order issues by field. If provided, the 'direction' also needs to be provided."), - mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), - ), - mcp.WithString("direction", - mcp.Description("Order direction. If provided, the 'orderBy' also needs to be provided."), - mcp.Enum("ASC", "DESC"), - ), - mcp.WithString("since", - mcp.Description("Filter by date (ISO 8601 timestamp)"), - ), - WithCursorPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := RequiredParam[string](request, "repo") + repo, err := RequiredParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Set optional parameters if provided - state, err := OptionalParam[string](request, "state") + state, err := OptionalParam[string](args, "state") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // If the state has a value, cast into an array of strings @@ -1259,19 +1342,19 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun } // Get labels - labels, err := OptionalStringArrayParam(request, "labels") + labels, err := OptionalStringArrayParam(args, "labels") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - orderBy, err := OptionalParam[string](request, "orderBy") + orderBy, err := OptionalParam[string](args, "orderBy") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } - direction, err := OptionalParam[string](request, "direction") + direction, err := OptionalParam[string](args, "direction") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // These variables are required for the GraphQL query to be set by default @@ -1284,9 +1367,9 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun direction = "DESC" } - since, err := OptionalParam[string](request, "since") + since, err := OptionalParam[string](args, "since") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // There are two optional parameters: since and labels. @@ -1295,30 +1378,30 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun if since != "" { sinceTime, err = parseISOTimestamp(since) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil + return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil } hasSince = true } hasLabels := len(labels) > 0 // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(request) + pagination, err := OptionalCursorPaginationParams(args) if err != nil { - return nil, err + return nil, nil, err } // Check if someone tried to use page-based pagination instead of cursor-based - if _, pageProvided := request.GetArguments()["page"]; pageProvided { - return mcp.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil + if _, pageProvided := args["page"]; pageProvided { + return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil } // Check if pagination parameters were explicitly provided - _, perPageProvided := request.GetArguments()["perPage"] + _, perPageProvided := args["perPage"] paginationExplicit := perPageProvided paginationParams, err := pagination.ToGraphQLParams() if err != nil { - return nil, err + return nil, nil, err } // Use default of 30 if pagination was not explicitly provided @@ -1329,7 +1412,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun client, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } vars := map[string]interface{}{ @@ -1364,7 +1447,7 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun issueQuery := getIssueQueryType(hasLabels, hasSince) if err := client.Query(ctx, issueQuery, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } // Extract and convert all issue nodes using the common interface @@ -1399,9 +1482,9 @@ func ListIssues(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun } out, err := json.Marshal(response) if err != nil { - return nil, fmt.Errorf("failed to marshal issues: %w", err) + return nil, nil, fmt.Errorf("failed to marshal issues: %w", err) } - return mcp.NewToolResultText(string(out)), nil + return utils.NewToolResultText(string(out)), nil, nil } } @@ -1435,7 +1518,7 @@ func (d *mvpDescription) String() string { return sb.String() } -func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { +func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { description := mvpDescription{ summary: "Assign Copilot to a specific issue in a GitHub repository.", outcomes: []string{ @@ -1446,39 +1529,46 @@ func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.Translatio }, } - return mcp.NewTool("assign_copilot_to_issue", - mcp.WithDescription(t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String())), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ + return mcp.Tool{ + Name: "assign_copilot_to_issue", + Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), + Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), - ReadOnlyHint: ToBoolPtr(false), - IdempotentHint: ToBoolPtr(true), - }), - mcp.WithString("owner", - mcp.Required(), - mcp.Description("Repository owner"), - ), - mcp.WithString("repo", - mcp.Required(), - mcp.Description("Repository name"), - ), - mcp.WithNumber("issueNumber", - mcp.Required(), - mcp.Description("Issue number"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ReadOnlyHint: false, + IdempotentHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issueNumber": { + Type: "number", + Description: "Issue number", + }, + }, + Required: []string{"owner", "repo", "issueNumber"}, + }, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string Repo string IssueNumber int32 } - if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil { - return mcp.NewToolResultError(err.Error()), nil + if err := mapstructure.Decode(args, ¶ms); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } client, err := getGQLClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } // Firstly, we try to find the copilot bot in the suggested actors for the repository. @@ -1515,7 +1605,7 @@ func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.Translatio var query suggestedActorsQuery err := client.Query(ctx, &query, variables) if err != nil { - return nil, err + return nil, nil, err } // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the @@ -1536,7 +1626,7 @@ func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.Translatio // If we didn't find the copilot bot, we can't proceed any further. if copilotAssignee == nil { // The e2e tests depend upon this specific message to skip the test. - return mcp.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil + return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil } // Next let's get the GQL Node ID and current assignees for this issue because the only way to @@ -1561,7 +1651,7 @@ func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.Translatio } if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil, nil } // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already @@ -1587,10 +1677,10 @@ func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.Translatio }, nil, ); err != nil { - return nil, fmt.Errorf("failed to replace actors for assignable: %w", err) + return nil, nil, fmt.Errorf("failed to replace actors for assignable: %w", err) } - return mcp.NewToolResultText("successfully assigned copilot to issue"), nil + return utils.NewToolResultText("successfully assigned copilot to issue"), nil, nil } } @@ -1623,37 +1713,56 @@ func parseISOTimestamp(timestamp string) (time.Time, error) { return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) } -func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { - return mcp.NewPrompt("AssignCodingAgent", - mcp.WithPromptDescription(t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository.")), - mcp.WithArgument("repo", mcp.ArgumentDescription("The repository to assign tasks in (owner/repo)."), mcp.RequiredArgument()), - ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { +func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (mcp.Prompt, mcp.PromptHandler) { + return mcp.Prompt{ + Name: "AssignCodingAgent", + Description: t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository."), + Arguments: []*mcp.PromptArgument{ + { + Name: "repo", + Description: "The repository to assign tasks in (owner/repo).", + Required: true, + }, + }, + }, func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { repo := request.Params.Arguments["repo"] - messages := []mcp.PromptMessage{ + messages := []*mcp.PromptMessage{ { - Role: "user", - Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."), + Role: "user", + Content: &mcp.TextContent{ + Text: "You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository.", + }, }, { - Role: "user", - Content: mcp.NewTextContent(fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo)), + Role: "user", + Content: &mcp.TextContent{ + Text: fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo), + }, }, { - Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo)), + Role: "assistant", + Content: &mcp.TextContent{ + Text: fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo), + }, }, { - Role: "user", - Content: mcp.NewTextContent("For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot."), + Role: "user", + Content: &mcp.TextContent{ + Text: "For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot.", + }, }, { - Role: "assistant", - Content: mcp.NewTextContent("Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now."), + Role: "assistant", + Content: &mcp.TextContent{ + Text: "Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now.", + }, }, { - Role: "user", - Content: mcp.NewTextContent("Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking."), + Role: "user", + Content: &mcp.TextContent{ + Text: "Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking.", + }, }, } return &mcp.GetPromptResult{ diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 60e6f57de..e200e335e 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1,5 +1,3 @@ -//go:build ignore - package github import ( @@ -15,6 +13,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" + "github.com/google/jsonschema-go/jsonschema" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -30,11 +29,11 @@ func Test_GetIssue(t *testing.T) { assert.Equal(t, "issue_read", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock issue for success case mockIssue := &github.Issue{ @@ -217,7 +216,7 @@ func Test_GetIssue(t *testing.T) { _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, flags) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) if tc.expectHandlerError { require.Error(t, err) @@ -258,11 +257,11 @@ func Test_AddIssueComment(t *testing.T) { assert.Equal(t, "add_issue_comment", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number", "body"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number", "body"}) // Setup mock comment for success case mockComment := &github.IssueComment{ @@ -331,7 +330,7 @@ func Test_AddIssueComment(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -372,14 +371,14 @@ func Test_SearchIssues(t *testing.T) { assert.Equal(t, "search_issues", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "sort") - assert.Contains(t, tool.InputSchema.Properties, "order") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "query") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sort") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "order") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"query"}) // Setup mock search results mockSearchResult := &github.IssuesSearchResult{ @@ -662,16 +661,20 @@ func Test_SearchIssues(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedErrMsg) + require.NoError(t, err) // No Go error, but result should be an error + require.NotNil(t, result) + require.True(t, result.IsError, "expected result to be an error") + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) return } require.NoError(t, err) + require.False(t, result.IsError, "expected result to not be an error") // Parse the result and get the text content if no error textContent := getTextResult(t, result) @@ -703,16 +706,16 @@ func Test_CreateIssue(t *testing.T) { assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "title") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "assignees") - assert.Contains(t, tool.InputSchema.Properties, "labels") - assert.Contains(t, tool.InputSchema.Properties, "milestone") - assert.Contains(t, tool.InputSchema.Properties, "type") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "title") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Setup mock issue for success case mockIssue := &github.Issue{ @@ -827,7 +830,7 @@ func Test_CreateIssue(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -864,16 +867,16 @@ func Test_ListIssues(t *testing.T) { assert.Equal(t, "list_issues", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.Contains(t, tool.InputSchema.Properties, "labels") - assert.Contains(t, tool.InputSchema.Properties, "orderBy") - assert.Contains(t, tool.InputSchema.Properties, "direction") - assert.Contains(t, tool.InputSchema.Properties, "since") - assert.Contains(t, tool.InputSchema.Properties, "after") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "orderBy") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "direction") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "since") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) // Mock issues data mockIssuesAll := []map[string]any{ @@ -1123,7 +1126,7 @@ func Test_ListIssues(t *testing.T) { _, handler := ListIssues(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper) req := createMCPRequest(tc.reqParams) - res, err := handler(context.Background(), req) + res, _, err := handler(context.Background(), &req, tc.reqParams) text := getTextResult(t, res).Text if tc.expectError { @@ -1173,20 +1176,20 @@ func Test_UpdateIssue(t *testing.T) { assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "title") - assert.Contains(t, tool.InputSchema.Properties, "body") - assert.Contains(t, tool.InputSchema.Properties, "labels") - assert.Contains(t, tool.InputSchema.Properties, "assignees") - assert.Contains(t, tool.InputSchema.Properties, "milestone") - assert.Contains(t, tool.InputSchema.Properties, "type") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.Contains(t, tool.InputSchema.Properties, "state_reason") - assert.Contains(t, tool.InputSchema.Properties, "duplicate_of") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "title") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "body") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "assignees") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Mock issues for reuse across test cases mockBaseIssue := &github.Issue{ @@ -1625,7 +1628,7 @@ func Test_UpdateIssue(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError || tc.expectedErrMsg != "" { @@ -1717,13 +1720,13 @@ func Test_GetIssueComments(t *testing.T) { assert.Equal(t, "issue_read", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock comments for success case mockComments := []*github.IssueComment{ @@ -1824,7 +1827,7 @@ func Test_GetIssueComments(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -1860,11 +1863,11 @@ func Test_GetIssueLabels(t *testing.T) { assert.Equal(t, "issue_read", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) tests := []struct { name string @@ -1933,7 +1936,7 @@ func Test_GetIssueLabels(t *testing.T) { _, handler := IssueRead(stubGetClientFn(client), stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper, stubFeatureFlags(map[string]bool{"lockdown-mode": false})) request := createMCPRequest(tc.requestArgs) - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) require.NoError(t, err) assert.NotNil(t, result) @@ -1961,10 +1964,10 @@ func TestAssignCopilotToIssue(t *testing.T) { assert.Equal(t, "assign_copilot_to_issue", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issueNumber") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issueNumber") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issueNumber"}) var pageOfFakeBots = func(n int) []struct{} { // We don't _really_ need real bots here, just objects that count as entries for the page @@ -2354,7 +2357,7 @@ func TestAssignCopilotToIssue(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) require.NoError(t, err) textContent := getTextResult(t, result) @@ -2379,13 +2382,13 @@ func Test_AddSubIssue(t *testing.T) { assert.Equal(t, "sub_issue_write", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") - assert.Contains(t, tool.InputSchema.Properties, "replace_parent") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "replace_parent") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) // Setup mock issue for success case (matches GitHub API response format) mockIssue := &github.Issue{ @@ -2582,7 +2585,7 @@ func Test_AddSubIssue(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -2626,13 +2629,13 @@ func Test_GetSubIssues(t *testing.T) { assert.Equal(t, "issue_read", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "page") - assert.Contains(t, tool.InputSchema.Properties, "perPage") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number"}) // Setup mock sub-issues for success case mockSubIssues := []*github.Issue{ @@ -2824,7 +2827,7 @@ func Test_GetSubIssues(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -2876,12 +2879,12 @@ func Test_RemoveSubIssue(t *testing.T) { assert.Equal(t, "sub_issue_write", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ @@ -3058,7 +3061,7 @@ func Test_RemoveSubIssue(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -3101,14 +3104,14 @@ func Test_ReprioritizeSubIssue(t *testing.T) { assert.Equal(t, "sub_issue_write", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "method") - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "repo") - assert.Contains(t, tool.InputSchema.Properties, "issue_number") - assert.Contains(t, tool.InputSchema.Properties, "sub_issue_id") - assert.Contains(t, tool.InputSchema.Properties, "after_id") - assert.Contains(t, tool.InputSchema.Properties, "before_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "sub_issue_id") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after_id") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "before_id") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}) // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ @@ -3344,7 +3347,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { @@ -3387,8 +3390,8 @@ func Test_ListIssueTypes(t *testing.T) { assert.Equal(t, "list_issue_types", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"}) // Setup mock issue types for success case mockIssueTypes := []*github.IssueType{ @@ -3475,7 +3478,7 @@ func Test_ListIssueTypes(t *testing.T) { request := createMCPRequest(tc.requestArgs) // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), &request, tc.requestArgs) // Verify results if tc.expectError { diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index fcc2a13d8..b055efb38 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,9 +1,6 @@ package github import ( - "fmt" - "time" - "github.com/google/go-github/v79/github" ) @@ -261,24 +258,3 @@ func convertToMinimalBranch(branch *github.Branch) MinimalBranch { Protected: branch.GetProtected(), } } - -// parseISOTimestamp parses an ISO 8601 timestamp string -func parseISOTimestamp(timestamp string) (time.Time, error) { - if timestamp == "" { - return time.Time{}, fmt.Errorf("empty timestamp") - } - - // Try RFC3339 format (standard ISO 8601 with time) - t, err := time.Parse(time.RFC3339, timestamp) - if err == nil { - return t, nil - } - - // Try simple date format (YYYY-MM-DD) - t, err = time.Parse("2006-01-02", timestamp) - if err == nil { - return t, nil - } - - return time.Time{}, fmt.Errorf("invalid timestamp format: %s", timestamp) -} diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 4a710236b..9f7e41dec 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -1,5 +1,3 @@ -//go:build ignore - package github import ( @@ -10,8 +8,9 @@ import ( "net/http" "regexp" + "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" - "github.com/mark3labs/mcp-go/mcp" + "github.com/modelcontextprotocol/go-sdk/mcp" ) func hasFilter(query, filterType string) bool { @@ -40,44 +39,44 @@ func hasTypeFilter(query string) bool { func searchHandler( ctx context.Context, getClient GetClientFn, - request mcp.CallToolRequest, + args map[string]any, searchType string, errorPrefix string, ) (*mcp.CallToolResult, error) { - query, err := RequiredParam[string](request, "query") + query, err := RequiredParam[string](args, "query") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil } if !hasSpecificFilter(query, "is", searchType) { query = fmt.Sprintf("is:%s %s", searchType, query) } - owner, err := OptionalParam[string](request, "owner") + owner, err := OptionalParam[string](args, "owner") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil } - repo, err := OptionalParam[string](request, "repo") + repo, err := OptionalParam[string](args, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil } if owner != "" && repo != "" && !hasRepoFilter(query) { query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) } - sort, err := OptionalParam[string](request, "sort") + sort, err := OptionalParam[string](args, "sort") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil } - order, err := OptionalParam[string](request, "order") + order, err := OptionalParam[string](args, "order") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil } - pagination, err := OptionalPaginationParams(request) + pagination, err := OptionalPaginationParams(args) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil } opts := &github.SearchOptions{ @@ -92,26 +91,26 @@ func searchHandler( client, err := getClient(ctx) if err != nil { - return nil, fmt.Errorf("%s: failed to get GitHub client: %w", errorPrefix, err) + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil } result, resp, err := client.Search.Issues(ctx, query, opts) if err != nil { - return nil, fmt.Errorf("%s: %w", errorPrefix, err) + return utils.NewToolResultErrorFromErr(errorPrefix, err), nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("%s: failed to read response body: %w", errorPrefix, err) + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil } - return mcp.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil } r, err := json.Marshal(result) if err != nil { - return nil, fmt.Errorf("%s: failed to marshal response: %w", errorPrefix, err) + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 40551e6fd..2a7529181 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -198,23 +198,23 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG AddReadTools( toolsets.NewServerTool(GetRepositoryTree(getClient, t)), ) - // issues := toolsets.NewToolset(ToolsetMetadataIssues.ID, ToolsetMetadataIssues.Description). - // AddReadTools( - // toolsets.NewServerTool(IssueRead(getClient, getGQLClient, t, flags)), - // toolsets.NewServerTool(SearchIssues(getClient, t)), - // toolsets.NewServerTool(ListIssues(getGQLClient, t)), - // toolsets.NewServerTool(ListIssueTypes(getClient, t)), - // toolsets.NewServerTool(GetLabel(getGQLClient, t)), - // ). - // AddWriteTools( - // toolsets.NewServerTool(IssueWrite(getClient, getGQLClient, t)), - // toolsets.NewServerTool(AddIssueComment(getClient, t)), - // toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), - // toolsets.NewServerTool(SubIssueWrite(getClient, t)), - // ).AddPrompts( - // toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), - // toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), - // ) + issues := toolsets.NewToolset(ToolsetMetadataIssues.ID, ToolsetMetadataIssues.Description). + AddReadTools( + toolsets.NewServerTool(IssueRead(getClient, getGQLClient, t, flags)), + toolsets.NewServerTool(SearchIssues(getClient, t)), + toolsets.NewServerTool(ListIssues(getGQLClient, t)), + toolsets.NewServerTool(ListIssueTypes(getClient, t)), + // toolsets.NewServerTool(GetLabel(getGQLClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(IssueWrite(getClient, getGQLClient, t)), + toolsets.NewServerTool(AddIssueComment(getClient, t)), + toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), + toolsets.NewServerTool(SubIssueWrite(getClient, t)), + ).AddPrompts( + toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), + toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), + ) // users := toolsets.NewToolset(ToolsetMetadataUsers.ID, ToolsetMetadataUsers.Description). // AddReadTools( // toolsets.NewServerTool(SearchUsers(getClient, t)), @@ -362,7 +362,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(contextTools) // tsg.AddToolset(repos) tsg.AddToolset(git) - // tsg.AddToolset(issues) + tsg.AddToolset(issues) // tsg.AddToolset(orgs) // tsg.AddToolset(users) // tsg.AddToolset(pullRequests) diff --git a/pkg/github/workflow_prompts.go b/pkg/github/workflow_prompts.go index 42b6d51c8..bc7c7581f 100644 --- a/pkg/github/workflow_prompts.go +++ b/pkg/github/workflow_prompts.go @@ -5,21 +5,48 @@ import ( "fmt" "github.com/github/github-mcp-server/pkg/translations" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // IssueToFixWorkflowPrompt provides a guided workflow for creating an issue and then generating a PR to fix it -func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) { - return mcp.NewPrompt("IssueToFixWorkflow", - mcp.WithPromptDescription(t("PROMPT_ISSUE_TO_FIX_WORKFLOW_DESCRIPTION", "Create an issue for a problem and then generate a pull request to fix it")), - mcp.WithArgument("owner", mcp.ArgumentDescription("Repository owner"), mcp.RequiredArgument()), - mcp.WithArgument("repo", mcp.ArgumentDescription("Repository name"), mcp.RequiredArgument()), - mcp.WithArgument("title", mcp.ArgumentDescription("Issue title"), mcp.RequiredArgument()), - mcp.WithArgument("description", mcp.ArgumentDescription("Issue description"), mcp.RequiredArgument()), - mcp.WithArgument("labels", mcp.ArgumentDescription("Comma-separated list of labels to apply (optional)")), - mcp.WithArgument("assignees", mcp.ArgumentDescription("Comma-separated list of assignees (optional)")), - ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { +func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler mcp.PromptHandler) { + return mcp.Prompt{ + Name: "issue_to_fix_workflow", + Description: t("PROMPT_ISSUE_TO_FIX_WORKFLOW_DESCRIPTION", "Create an issue for a problem and then generate a pull request to fix it"), + Arguments: []*mcp.PromptArgument{ + { + Name: "owner", + Description: "Repository owner", + Required: true, + }, + { + Name: "repo", + Description: "Repository name", + Required: true, + }, + { + Name: "title", + Description: "Issue title", + Required: true, + }, + { + Name: "description", + Description: "Issue description", + Required: true, + }, + { + Name: "labels", + Description: "Comma-separated list of labels to apply (optional)", + Required: false, + }, + { + Name: "assignees", + Description: "Comma-separated list of assignees (optional)", + Required: false, + }, + }, + }, + func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { owner := request.Params.Arguments["owner"] repo := request.Params.Arguments["repo"] title := request.Params.Arguments["title"] @@ -35,14 +62,16 @@ func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr assignees = fmt.Sprintf("%v", a) } - messages := []mcp.PromptMessage{ + messages := []*mcp.PromptMessage{ { - Role: "user", - Content: mcp.NewTextContent("You are a development workflow assistant helping to create GitHub issues and generate corresponding pull requests to fix them. You should: 1) Create a well-structured issue with clear problem description, 2) Assign it to Copilot coding agent to generate a solution, and 3) Monitor the PR creation process."), + Role: "user", + Content: &mcp.TextContent{ + Text: "You are a development workflow assistant helping to create GitHub issues and generate corresponding pull requests to fix them. You should: 1) Create a well-structured issue with clear problem description, 2) Assign it to Copilot coding agent to generate a solution, and 3) Monitor the PR creation process.", + }, }, { Role: "user", - Content: mcp.NewTextContent(fmt.Sprintf("I need to create an issue titled '%s' in %s/%s and then have a PR generated to fix it. The issue description is: %s%s%s", + Content: &mcp.TextContent{Text: fmt.Sprintf("I need to create an issue titled '%s' in %s/%s and then have a PR generated to fix it. The issue description is: %s%s%s", title, owner, repo, description, func() string { if labels != "" { @@ -55,19 +84,19 @@ func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr return fmt.Sprintf("\nAssignees: %s", assignees) } return "" - }())), + }())}, }, { Role: "assistant", - Content: mcp.NewTextContent(fmt.Sprintf("I'll help you create the issue '%s' in %s/%s and then coordinate with Copilot to generate a fix. Let me start by creating the issue with the provided details.", title, owner, repo)), + Content: &mcp.TextContent{Text: fmt.Sprintf("I'll help you create the issue '%s' in %s/%s and then coordinate with Copilot to generate a fix. Let me start by creating the issue with the provided details.", title, owner, repo)}, }, { Role: "user", - Content: mcp.NewTextContent("Perfect! Please:\n1. Create the issue with the title, description, labels, and assignees\n2. Once created, assign it to Copilot coding agent to generate a solution\n3. Monitor the process and let me know when the PR is ready for review"), + Content: &mcp.TextContent{Text: "Perfect! Please:\n1. Create the issue with the title, description, labels, and assignees\n2. Once created, assign it to Copilot coding agent to generate a solution\n3. Monitor the process and let me know when the PR is ready for review"}, }, { Role: "assistant", - Content: mcp.NewTextContent("Excellent plan! Here's what I'll do:\n\n1. ✅ Create the issue with all specified details\n2. 🤖 Assign to Copilot coding agent for automated fix\n3. 📋 Monitor progress and notify when PR is created\n4. 🔍 Provide PR details for your review\n\nLet me start by creating the issue."), + Content: &mcp.TextContent{Text: "Excellent plan! Here's what I'll do:\n\n1. ✅ Create the issue with all specified details\n2. 🤖 Assign to Copilot coding agent for automated fix\n3. 📋 Monitor progress and notify when PR is created\n4. 🔍 Provide PR details for your review\n\nLet me start by creating the issue."}, }, } return &mcp.GetPromptResult{