package github import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) const ( ProjectUpdateFailedError = "failed to update a project item" ProjectAddFailedError = "failed to add a project item" ProjectDeleteFailedError = "failed to delete a project item" ProjectListFailedError = "failed to list project items" ProjectStatusUpdateListFailedError = "failed to list project status updates" ProjectStatusUpdateGetFailedError = "failed to get project status update" ProjectStatusUpdateCreateFailedError = "failed to create project status update" ProjectResolveIDFailedError = "failed to resolve project ID" MaxProjectsPerPage = 50 ) // Method constants for consolidated project tools const ( projectsMethodListProjects = "list_projects" projectsMethodListProjectFields = "list_project_fields" projectsMethodListProjectItems = "list_project_items" projectsMethodGetProject = "get_project" projectsMethodGetProjectField = "get_project_field" projectsMethodGetProjectItem = "get_project_item" projectsMethodAddProjectItem = "add_project_item" projectsMethodUpdateProjectItem = "update_project_item" projectsMethodDeleteProjectItem = "delete_project_item" projectsMethodListProjectStatusUpdates = "list_project_status_updates" projectsMethodGetProjectStatusUpdate = "get_project_status_update" projectsMethodCreateProjectStatusUpdate = "create_project_status_update" projectsMethodCreateProject = "create_project" projectsMethodCreateIterationField = "create_iteration_field" ) // GraphQL types for ProjectV2 status updates type statusUpdateNode struct { ID githubv4.ID Body *githubv4.String Status *githubv4.String CreatedAt githubv4.DateTime StartDate *githubv4.String TargetDate *githubv4.String Creator struct { Login githubv4.String } } type projectVisibility struct { Public githubv4.Boolean } type statusUpdateNodeWithProject struct { statusUpdateNode Project projectVisibility } type statusUpdateConnection struct { Nodes []statusUpdateNode PageInfo PageInfoFragment } type statusUpdatesProject struct { Public githubv4.Boolean StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` } // statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project. type statusUpdatesUserQuery struct { User struct { ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"` } `graphql:"user(login: $owner)"` } // statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project. type statusUpdatesOrgQuery struct { Organization struct { ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"` } `graphql:"organization(login: $owner)"` } // statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID. type statusUpdateNodeQuery struct { Node struct { StatusUpdate statusUpdateNodeWithProject `graphql:"... on ProjectV2StatusUpdate"` } `graphql:"node(id: $id)"` } // CreateProjectV2StatusUpdateInput is the input for the createProjectV2StatusUpdate mutation. // Defined locally because the shurcooL/githubv4 library does not include this type. type CreateProjectV2StatusUpdateInput struct { ProjectID githubv4.ID `json:"projectId"` Body *githubv4.String `json:"body,omitempty"` Status *githubv4.String `json:"status,omitempty"` StartDate *githubv4.String `json:"startDate,omitempty"` TargetDate *githubv4.String `json:"targetDate,omitempty"` ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` } // validProjectV2StatusUpdateStatuses is the set of valid status values for the createProjectV2StatusUpdate mutation. var validProjectV2StatusUpdateStatuses = map[string]bool{ "INACTIVE": true, "ON_TRACK": true, "AT_RISK": true, "OFF_TRACK": true, "COMPLETE": true, } func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpdate { var creator *MinimalUser if login := string(node.Creator.Login); login != "" { creator = &MinimalUser{Login: login} } return MinimalProjectStatusUpdate{ ID: fmt.Sprintf("%v", node.ID), Body: derefString(node.Body), Status: derefString(node.Status), CreatedAt: node.CreatedAt.Time.Format(time.RFC3339), StartDate: derefString(node.StartDate), TargetDate: derefString(node.TargetDate), Creator: creator, } } func derefString(s *githubv4.String) string { if s == nil { return "" } return string(*s) } // ProjectsList returns the tool and handler for listing GitHub Projects resources. func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "projects_list", Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", `Tools for listing GitHub Projects resources. Use this tool to list projects for a user or organization, or list project fields and items for a specific project. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", Description: "The action to perform", Enum: []any{ projectsMethodListProjects, projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates, }, }, "owner_type": { Type: "string", Description: "Owner type (user or org). If not provided, will automatically try both.", Enum: []any{"user", "org"}, }, "owner": { Type: "string", Description: "The owner (user or organization login). The name is not case sensitive.", }, "project_number": { Type: "number", Description: "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", }, "query": { Type: "string", Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`, }, "fields": { Type: "array", Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'list_project_items' method.", Items: &jsonschema.Schema{ Type: "string", }, }, "field_names": { Type: "array", Description: "Field names to include when listing project items (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Mutually exclusive with 'fields' — provide one, not both. Only used for 'list_project_items' method.", Items: &jsonschema.Schema{ Type: "string", }, }, "per_page": { Type: "number", Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), }, "after": { Type: "string", Description: "Forward pagination cursor from previous pageInfo.nextCursor.", }, "before": { Type: "string", Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", }, }, Required: []string{"method", "owner"}, }, }, []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } ownerType, err := OptionalParam[string](args, "owner_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } switch method { case projectsMethodListProjects: result, visibilities, payload, err := listProjects(ctx, client, args, owner, ownerType) result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelProjectList) return result, payload, err case projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates: // All other methods require project_number and ownerType detection projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if ownerType == "" { ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } switch method { case projectsMethodListProjectFields: result, payload, err := listProjectFields(ctx, client, args, owner, ownerType) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) if visibilityErr == nil { result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject) } } return result, payload, err case projectsMethodListProjectItems: gqlClient, gqlErr := deps.GetGQLClient(ctx) if gqlErr != nil { return utils.NewToolResultError(gqlErr.Error()), nil, nil } result, payload, err := listProjectItems(ctx, client, gqlClient, args, owner, ownerType) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) if visibilityErr == nil { result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent) } } return result, payload, err case projectsMethodListProjectStatusUpdates: gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } result, isPrivate, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate)) return result, payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) return tool } // ProjectsGet returns the tool and handler for getting GitHub Projects resources. func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "projects_get", Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. Use this tool to get details about individual projects, project fields, and project items by their unique IDs. `), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", Description: "The method to execute", Enum: []any{ projectsMethodGetProject, projectsMethodGetProjectField, projectsMethodGetProjectItem, projectsMethodGetProjectStatusUpdate, }, }, "owner_type": { Type: "string", Description: "Owner type (user or org). If not provided, will be automatically detected.", Enum: []any{"user", "org"}, }, "owner": { Type: "string", Description: "The owner (user or organization login). The name is not case sensitive.", }, "project_number": { Type: "number", Description: "The project's number.", }, "field_id": { Type: "number", Description: "The field's ID. Required for 'get_project_field' method.", }, "item_id": { Type: "number", Description: "The item's ID. Required for 'get_project_item' method.", }, "fields": { Type: "array", Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'get_project_item' method.", Items: &jsonschema.Schema{ Type: "string", }, }, "field_names": { Type: "array", Description: "Specific list of field names to include in the response when getting a project item (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Mutually exclusive with 'fields' — provide one, not both. Only used for 'get_project_item' method.", Items: &jsonschema.Schema{ Type: "string", }, }, "status_update_id": { Type: "string", Description: "The node ID of the project status update. Required for 'get_project_status_update' method.", }, }, Required: []string{"method"}, }, }, []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Handle get_project_status_update early — it only needs status_update_id if method == projectsMethodGetProjectStatusUpdate { statusUpdateID, err := RequiredParam[string](args, "status_update_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } result, isPrivate, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate)) return result, payload, err } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } ownerType, err := OptionalParam[string](args, "owner_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Detect owner type if not provided if ownerType == "" { ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } switch method { case projectsMethodGetProject: result, isPrivate, payload, err := getProject(ctx, client, owner, ownerType, projectNumber) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProject(isPrivate)) return result, payload, err case projectsMethodGetProjectField: fieldID, err := RequiredBigInt(args, "field_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) if visibilityErr == nil { result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject) } } return result, payload, err case projectsMethodGetProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } fields, err := OptionalBigIntArrayParam(args, "fields") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } fieldNames, err := OptionalStringArrayParam(args, "field_names") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if len(fields) > 0 && len(fieldNames) > 0 { return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), nil, nil } if len(fieldNames) > 0 { gqlClient, gqlErr := deps.GetGQLClient(ctx) if gqlErr != nil { return utils.NewToolResultError(gqlErr.Error()), nil, nil } resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames) if resolveErr != nil { var structured *ghErrors.StructuredResolutionError if errors.As(resolveErr, &structured) { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(resolveErr.Error()), nil, nil } fields = append(fields, resolvedIDs...) } result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) if visibilityErr == nil { result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent) } } return result, payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) return tool } // ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "projects_write", Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"), ReadOnlyHint: false, DestructiveHint: jsonschema.Ptr(true), }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", Description: "The method to execute", Enum: []any{ projectsMethodAddProjectItem, projectsMethodUpdateProjectItem, projectsMethodDeleteProjectItem, projectsMethodCreateProjectStatusUpdate, projectsMethodCreateProject, projectsMethodCreateIterationField, }, }, "owner_type": { Type: "string", Description: "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.", Enum: []any{"user", "org"}, }, "owner": { Type: "string", Description: "The project owner (user or organization login). The name is not case sensitive.", }, "project_number": { Type: "number", Description: "The project's number. Required for all methods except 'create_project'.", }, "title": { Type: "string", Description: "The project title. Required for 'create_project' method.", }, "item_id": { Type: "number", Description: "The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue.", }, "item_type": { Type: "string", Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.", Enum: []any{"issue", "pull_request"}, }, "item_owner": { Type: "string", Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.", }, "item_repo": { Type: "string", Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.", }, "issue_number": { Type: "number", Description: "The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo).", }, "pull_request_number": { Type: "number", Description: "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.", }, "updated_field": { Type: "object", Description: "Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {\"id\": 123456, \"value\": \"...\"}; (2) by name — {\"name\": \"Status\", \"value\": \"In Progress\"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field.", }, "body": { Type: "string", Description: "The body of the status update (markdown). Used for 'create_project_status_update' method.", }, "status": { Type: "string", Description: "The status of the project. Used for 'create_project_status_update' method.", Enum: []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"}, }, "start_date": { Type: "string", Description: "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.", }, "target_date": { Type: "string", Description: "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", }, "field_name": { Type: "string", Description: "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.", }, "iteration_duration": { Type: "number", Description: "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.", }, "iterations": { Type: "array", Description: "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.", Items: &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, Properties: map[string]*jsonschema.Schema{ "title": { Type: "string", Description: "Iteration title (e.g. 'Sprint 1')", }, "start_date": { Type: "string", Description: "Start date in YYYY-MM-DD format", }, "duration": { Type: "number", Description: "Duration in days", }, }, Required: []string{"title", "start_date", "duration"}, }, }, }, Required: []string{"method", "owner"}, }, }, []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } ownerType, err := OptionalParam[string](args, "owner_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // create_project does not require project_number or a REST client if method == projectsMethodCreateProject { return createProject(ctx, gqlClient, owner, ownerType, args) } projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Detect owner type if not provided if ownerType == "" { ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } switch method { case projectsMethodAddProjectItem: itemType, err := RequiredParam[string](args, "item_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } itemOwner, err := RequiredParam[string](args, "item_owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } itemRepo, err := RequiredParam[string](args, "item_repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } var itemNumber int switch itemType { case "issue": itemNumber, err = RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError("issue_number is required when item_type is 'issue'"), nil, nil } case "pull_request": itemNumber, err = RequiredInt(args, "pull_request_number") if err != nil { return utils.NewToolResultError("pull_request_number is required when item_type is 'pull_request'"), nil, nil } default: return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil } return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType) case projectsMethodUpdateProjectItem: var itemID int64 if _, hasItemID := args["item_id"]; hasItemID { id, err := RequiredBigInt(args, "item_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } itemID = id } else { // Resolve the item by (item_owner, item_repo, issue_number). resolvedItemID, resolveErr := resolveItemIDFromIssueArgs(ctx, gqlClient, owner, ownerType, projectNumber, args) if resolveErr != nil { var structured *ghErrors.StructuredResolutionError if errors.As(resolveErr, &structured) { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(resolveErr.Error()), nil, nil } itemID = resolvedItemID } rawUpdatedField, exists := args["updated_field"] if !exists { return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil } fieldValue, ok := rawUpdatedField.(map[string]any) if !ok || fieldValue == nil { return utils.NewToolResultError("updated_field must be an object"), nil, nil } return updateProjectItem(ctx, client, gqlClient, owner, ownerType, projectNumber, itemID, fieldValue) case projectsMethodDeleteProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) case projectsMethodCreateProjectStatusUpdate: body, err := OptionalParam[string](args, "body") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } status, err := OptionalParam[string](args, "status") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } startDate, err := OptionalParam[string](args, "start_date") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } targetDate, err := OptionalParam[string](args, "target_date") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate) case projectsMethodCreateIterationField: return createIterationField(ctx, gqlClient, owner, ownerType, projectNumber, args) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) return tool } // Helper functions for consolidated projects tools func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, []bool, any, error) { queryStr, err := OptionalParam[string](args, "query") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil, nil } pagination, err := extractPaginationOptionsFromArgs(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil, nil } var resp *github.Response var projects []*github.ProjectV2 minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, Query: queryStr, } // If owner_type not provided, fetch from both user and org switch ownerType { case "": return listProjectsFromBothOwnerTypes(ctx, client, owner, opts) case "org": projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list projects", resp, err, ), nil, nil, nil } default: projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list projects", resp, err, ), nil, nil, nil } } // For specified owner_type, process normally if ownerType != "" { defer func() { _ = resp.Body.Close() }() for _, project := range projects { mp := convertToMinimalProject(project) mp.OwnerType = ownerType minimalProjects = append(minimalProjects, *mp) } response := map[string]any{ "projects": minimalProjects, "pageInfo": buildPageInfo(resp), } r, err := json.Marshal(response) if err != nil { return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil } return nil, nil, nil, fmt.Errorf("unexpected state in listProjects") } // listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints // when owner_type is not specified, combining the results with owner_type labels. func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, []bool, any, error) { var minimalProjects []MinimalProject var resp *github.Response // Fetch user projects userProjects, userResp, userErr := client.Projects.ListUserProjects(ctx, owner, opts) if userErr == nil && userResp.StatusCode == http.StatusOK { for _, project := range userProjects { mp := convertToMinimalProject(project) mp.OwnerType = "user" minimalProjects = append(minimalProjects, *mp) } _ = userResp.Body.Close() } // Fetch org projects orgProjects, orgResp, orgErr := client.Projects.ListOrganizationProjects(ctx, owner, opts) if orgErr == nil && orgResp.StatusCode == http.StatusOK { for _, project := range orgProjects { mp := convertToMinimalProject(project) mp.OwnerType = "org" minimalProjects = append(minimalProjects, *mp) } resp = orgResp // Use org response for pagination info } else if userResp != nil { resp = userResp // Fallback to user response } // If both failed, return error if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) && (orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) { return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil, nil } response := map[string]any{ "projects": minimalProjects, "note": "Results include both user and org projects. Each project includes 'owner_type' field. Pagination is limited when owner_type is not specified - specify 'owner_type' for full pagination support.", } if resp != nil { response["pageInfo"] = buildPageInfo(resp) defer func() { _ = resp.Body.Close() }() } r, err := json.Marshal(response) if err != nil { return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil } func projectVisibilities(projects []MinimalProject) []bool { visibilities := make([]bool, 0, len(projects)) for _, project := range projects { isPrivate := true if project.Public != nil { isPrivate = !*project.Public } visibilities = append(visibilities, isPrivate) } return visibilities } func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } pagination, err := extractPaginationOptionsFromArgs(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } var resp *github.Response var projectFields []*github.ProjectV2Field opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, } if ownerType == "org" { projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) } else { projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list project fields", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() response := map[string]any{ "fields": projectFields, "pageInfo": buildPageInfo(resp), } r, err := json.Marshal(response) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil } func listProjectItems(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } queryStr, err := OptionalParam[string](args, "query") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } fields, err := OptionalBigIntArrayParam(args, "fields") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } fieldNames, err := OptionalStringArrayParam(args, "field_names") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } if len(fields) > 0 && len(fieldNames) > 0 { return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), nil, nil } if len(fieldNames) > 0 { resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames) if resolveErr != nil { var structured *ghErrors.StructuredResolutionError if errors.As(resolveErr, &structured) { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(resolveErr.Error()), nil, nil } fields = append(fields, resolvedIDs...) } pagination, err := extractPaginationOptionsFromArgs(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } var resp *github.Response var projectItems []*github.ProjectV2Item opts := &github.ListProjectItemsOptions{ Fields: fields, ListProjectsOptions: github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, Query: queryStr, }, } if ownerType == "org" { projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) } else { projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectListFailedError, resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() minimalItems := make([]MinimalProjectItem, 0, len(projectItems)) for _, item := range projectItems { minimalItems = append(minimalItems, convertToMinimalProjectItem(item)) } response := map[string]any{ "items": minimalItems, "pageInfo": buildPageInfo(resp), } r, err := json.Marshal(response) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil } func fetchProjectV2(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*github.ProjectV2, *github.Response, error) { if ownerType == "org" { return client.Projects.GetOrganizationProject(ctx, owner, projectNumber) } return client.Projects.GetUserProject(ctx, owner, projectNumber) } // FetchProjectIsPrivate returns whether a GitHub Project is private. func FetchProjectIsPrivate(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (bool, error) { project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber) if resp != nil && resp.Body != nil { defer func() { _ = resp.Body.Close() }() } if err != nil { return false, err } if resp == nil || resp.StatusCode != http.StatusOK { return false, fmt.Errorf("failed to fetch project visibility") } return !project.GetPublic(), nil } func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, bool, any, error) { project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project", resp, err, ), false, nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, false, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), false, nil, nil } minimalProject := convertToMinimalProject(project) r, err := json.Marshal(minimalProject) if err != nil { return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), !project.GetPublic(), nil, nil } func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var projectField *github.ProjectV2Field var err error if ownerType == "org" { projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) } else { projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project field", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project field", resp, body), nil, nil } r, err := json.Marshal(projectField) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil } func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var projectItem *github.ProjectV2Item var opts *github.GetProjectItemOptions var err error if len(fields) > 0 { opts = &github.GetProjectItemOptions{ Fields: fields, } } if ownerType == "org" { projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) } else { projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get project item", resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil } r, err := json.Marshal(convertToMinimalProjectItem(projectItem)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil } func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { updatePayload, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue) if err != nil { var structured *ghErrors.StructuredResolutionError if errors.As(err, &structured) { return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil } return utils.NewToolResultError(err.Error()), nil, nil } var resp *github.Response var updatedItem *github.ProjectV2Item if ownerType == "org" { updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) } else { updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectUpdateFailedError, resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil } r, err := json.Marshal(convertToMinimalProjectItem(updatedItem)) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil } func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { var resp *github.Response var err error if ownerType == "org" { resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) } else { resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) } if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectDeleteFailedError, resp, err, ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { body, err := io.ReadAll(resp.Body) if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil } return utils.NewToolResultText("project item successfully deleted"), nil, nil } // resolveProjectNodeID resolves (owner, ownerType, projectNumber) to a project node ID via GraphQL. func resolveProjectNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) (githubv4.ID, error) { var projectIDQueryUser struct { User struct { ProjectV2 struct { ID githubv4.ID } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"user(login: $owner)"` } var projectIDQueryOrg struct { Organization struct { ProjectV2 struct { ID githubv4.ID } `graphql:"projectV2(number: $projectNumber)"` } `graphql:"organization(login: $owner)"` } queryVars := map[string]any{ "owner": githubv4.String(owner), "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers } if ownerType == "org" { err := gqlClient.Query(ctx, &projectIDQueryOrg, queryVars) if err != nil { return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) } return projectIDQueryOrg.Organization.ProjectV2.ID, nil } err := gqlClient.Query(ctx, &projectIDQueryUser, queryVars) if err != nil { return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) } return projectIDQueryUser.User.ProjectV2.ID, nil } // addProjectItem adds an item to a project by resolving the issue/PR number to a node ID func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) { if itemType != "issue" && itemType != "pull_request" { return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil } // Resolve the item number to a node ID var nodeID githubv4.ID var err error if itemType == "issue" { nodeID, err = resolveIssueNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber) } else { nodeID, err = resolvePullRequestNodeID(ctx, gqlClient, itemOwner, itemRepo, itemNumber) } if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to resolve %s: %v", itemType, err)), nil, nil } // Use GraphQL to add the item to the project var mutation struct { AddProjectV2ItemByID struct { Item struct { ID githubv4.ID FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` } // Resolve the project number to a node ID projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Add the item to the project input := githubv4.AddProjectV2ItemByIdInput{ ProjectID: projectID, ContentID: nodeID, } err = gqlClient.Mutate(ctx, &mutation, input, nil) if err != nil { return utils.NewToolResultError(fmt.Sprintf(ProjectAddFailedError+": %v", err)), nil, nil } result := map[string]any{ "id": mutation.AddProjectV2ItemByID.Item.ID, "message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber), } if fullDatabaseID := mutation.AddProjectV2ItemByID.Item.FullDatabaseID; fullDatabaseID != "" { result["full_database_id"] = fullDatabaseID if itemID, err := strconv.ParseInt(fullDatabaseID, 10, 64); err == nil { result["item_id"] = itemID } } r, err := json.Marshal(result) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil } // validateDateFormat checks that a date string is in YYYY-MM-DD format. func validateDateFormat(value, fieldName string) error { if _, err := time.Parse("2006-01-02", value); err != nil { return fmt.Errorf("invalid %s %q: must be YYYY-MM-DD format", fieldName, value) } return nil } // createProjectStatusUpdate creates a new status update for a project via GraphQL. func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, body, status, startDate, targetDate string) (*mcp.CallToolResult, any, error) { // Validate inputs if ownerType != "user" && ownerType != "org" { return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil } if status != "" && !validProjectV2StatusUpdateStatuses[status] { return utils.NewToolResultError(fmt.Sprintf("invalid status %q: must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE", status)), nil, nil } if startDate != "" { if err := validateDateFormat(startDate, "start_date"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } if targetDate != "" { if err := validateDateFormat(targetDate, "target_date"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } } // Resolve project number to project node ID projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Build mutation input input := CreateProjectV2StatusUpdateInput{ ProjectID: projectID, } if body != "" { s := githubv4.String(body) input.Body = &s } if status != "" { s := githubv4.String(status) input.Status = &s } if startDate != "" { s := githubv4.String(startDate) input.StartDate = &s } if targetDate != "" { s := githubv4.String(targetDate) input.TargetDate = &s } // Execute mutation var mutation struct { CreateProjectV2StatusUpdate struct { StatusUpdate statusUpdateNode } `graphql:"createProjectV2StatusUpdate(input: $input)"` } err = gqlClient.Mutate(ctx, &mutation, input, nil) if err != nil { return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateCreateFailedError, err)), nil, nil } // Convert and return result := convertToMinimalStatusUpdate(mutation.CreateProjectV2StatusUpdate.StatusUpdate) r, err := json.Marshal(result) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), nil, nil } // listProjectStatusUpdates lists status updates for a project via GraphQL. func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, bool, any, error) { if ownerType != "user" && ownerType != "org" { return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), false, nil, nil } projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } if perPage > MaxProjectsPerPage { perPage = MaxProjectsPerPage } if perPage < 1 { perPage = MaxProjectsPerPage } afterCursor, err := OptionalParam[string](args, "after") if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } vars := map[string]any{ "owner": githubv4.String(owner), "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers "first": githubv4.Int(int32(perPage)), //nolint:gosec // perPage is bounded by MaxProjectsPerPage } if afterCursor != "" { vars["after"] = githubv4.String(afterCursor) } else { vars["after"] = (*githubv4.String)(nil) } var nodes []statusUpdateNode var pi PageInfoFragment var isPrivate bool if ownerType == "org" { var q statusUpdatesOrgQuery if err := gqlClient.Query(ctx, &q, vars); err != nil { return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil } project := q.Organization.ProjectV2 nodes = project.StatusUpdates.Nodes pi = project.StatusUpdates.PageInfo isPrivate = !bool(project.Public) } else { var q statusUpdatesUserQuery if err := gqlClient.Query(ctx, &q, vars); err != nil { return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil } project := q.User.ProjectV2 nodes = project.StatusUpdates.Nodes pi = project.StatusUpdates.PageInfo isPrivate = !bool(project.Public) } updates := make([]MinimalProjectStatusUpdate, 0, len(nodes)) for _, n := range nodes { updates = append(updates, convertToMinimalStatusUpdate(n)) } response := map[string]any{ "statusUpdates": updates, "pageInfo": map[string]any{ "hasNextPage": pi.HasNextPage, "hasPreviousPage": pi.HasPreviousPage, "nextCursor": string(pi.EndCursor), "prevCursor": string(pi.StartCursor), }, } r, err := json.Marshal(response) if err != nil { return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), isPrivate, nil, nil } // getProjectStatusUpdate fetches a single status update by its node ID via GraphQL. func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, bool, any, error) { var q statusUpdateNodeQuery vars := map[string]any{ "id": githubv4.ID(statusUpdateID), } if err := gqlClient.Query(ctx, &q, vars); err != nil { return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), false, nil, nil } if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" { return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), false, nil, nil } update := convertToMinimalStatusUpdate(q.Node.StatusUpdate.statusUpdateNode) isPrivate := !bool(q.Node.StatusUpdate.Project.Public) r, err := json.Marshal(update) if err != nil { return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err) } return utils.NewToolResultText(string(r)), isPrivate, nil, nil } // validateAndConvertToInt64 ensures the value is a number and converts it to int64. func validateAndConvertToInt64(value any) (int64, error) { switch v := value.(type) { case float64: // Validate that the float64 can be safely converted to int64 intVal := int64(v) if float64(intVal) != v { return 0, fmt.Errorf("value must be a valid integer (got %v)", v) } return intVal, nil case int64: return v, nil case int: return int64(v), nil default: return 0, fmt.Errorf("value must be a number (got %T)", v) } } // buildUpdateProjectItem builds UpdateProjectItemOptions, resolving field names and SINGLE_SELECT option names server-side. func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") } valueField, hasValue := input["value"] if !hasValue { return nil, fmt.Errorf("updated_field.value is required") } idField, hasID := input["id"] nameField, hasName := input["name"] switch { case hasID && hasName: return nil, fmt.Errorf("updated_field must set either id or name, not both") case !hasID && !hasName: return nil, fmt.Errorf("updated_field requires either id or name") } var ( fieldID int64 resolved *ResolvedField ) if hasID { var err error fieldID, err = validateAndConvertToInt64(idField) if err != nil { return nil, fmt.Errorf("updated_field.id: %w", err) } } else { fieldName, ok := nameField.(string) if !ok || fieldName == "" { return nil, fmt.Errorf("updated_field.name must be a non-empty string") } if gqlClient == nil { return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.name") } var err error resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") if err != nil { return nil, err } parsedID, parseErr := parseInt64(resolved.ID) if parseErr != nil { return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) } fieldID = parsedID } // SINGLE_SELECT: resolve option name to ID; pass through if it's already a known option ID. if resolved != nil && resolved.DataType == "SINGLE_SELECT" { if str, ok := valueField.(string); ok && str != "" { if optID, optErr := resolveSingleSelectOptionByName(resolved, str); optErr == nil { valueField = optID } else { // Fall back: if the string is already a known option ID, accept it. known := false for _, opt := range resolved.Options { if opt.ID == str { known = true break } } if !known { return nil, optErr } } } } payload := &github.UpdateProjectItemOptions{ Fields: []*github.UpdateProjectV2Field{{ ID: fieldID, Value: valueField, }}, } return payload, nil } func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) if err != nil { return github.ListProjectsPaginationOptions{}, err } if perPage > MaxProjectsPerPage { perPage = MaxProjectsPerPage } after, err := OptionalParam[string](args, "after") if err != nil { return github.ListProjectsPaginationOptions{}, err } before, err := OptionalParam[string](args, "before") if err != nil { return github.ListProjectsPaginationOptions{}, err } opts := github.ListProjectsPaginationOptions{ PerPage: perPage, After: after, Before: before, } return opts, nil } // resolveIssueNodeID resolves an issue number to its GraphQL node ID func resolveIssueNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) (githubv4.ID, error) { var query struct { Repository struct { Issue struct { ID githubv4.ID } `graphql:"issue(number: $issueNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } variables := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small integers } err := gqlClient.Query(ctx, &query, variables) if err != nil { return "", fmt.Errorf("failed to resolve issue %s/%s#%d: %w", owner, repo, issueNumber, err) } return query.Repository.Issue.ID, nil } // resolvePullRequestNodeID resolves a pull request number to its GraphQL node ID func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, prNumber int) (githubv4.ID, error) { var query struct { Repository struct { PullRequest struct { ID githubv4.ID } `graphql:"pullRequest(number: $prNumber)"` } `graphql:"repository(owner: $owner, name: $repo)"` } variables := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), "prNumber": githubv4.Int(int32(prNumber)), //nolint:gosec // PR numbers are small integers } err := gqlClient.Query(ctx, &query, variables) if err != nil { return "", fmt.Errorf("failed to resolve pull request %s/%s#%d: %w", owner, repo, prNumber, err) } return query.Repository.PullRequest.ID, nil } // createProject handles the create_project method for ProjectsWrite. func createProject(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, args map[string]any) (*mcp.CallToolResult, any, error) { if ownerType == "" { return utils.NewToolResultError("owner_type is required for create_project"), nil, nil } if ownerType != "user" && ownerType != "org" { return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil } title, err := RequiredParam[string](args, "title") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } ownerID, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil } var mutation struct { CreateProjectV2 struct { ProjectV2 struct { ID string Number int Title string URL string } } `graphql:"createProjectV2(input: $input)"` } input := githubv4.CreateProjectV2Input{ OwnerID: githubv4.ID(ownerID), Title: githubv4.String(title), } err = gqlClient.Mutate(ctx, &mutation, input, nil) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil } result := struct { ID string `json:"id"` Number int `json:"number"` Title string `json:"title"` URL string `json:"url"` }{ ID: mutation.CreateProjectV2.ProjectV2.ID, Number: mutation.CreateProjectV2.ProjectV2.Number, Title: mutation.CreateProjectV2.ProjectV2.Title, URL: mutation.CreateProjectV2.ProjectV2.URL, } return MarshalledTextResult(result), nil, nil } // createIterationField handles the create_iteration_field method for ProjectsWrite. // // GitHub's GraphQL API requires two mutations to fully configure an iteration field: // 1. createProjectV2Field creates the field with DataType=ITERATION (no schedule yet). // 2. updateProjectV2Field sets the start date, duration, and optional named iterations. // // If step 2 fails, the field already exists with default settings and can be reconfigured // by calling this method again (the create will fail with a duplicate-name error, which // surfaces clearly) or by deleting the field via the GitHub UI. func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) { fieldName, err := RequiredParam[string](args, "field_name") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } duration, err := RequiredInt(args, "iteration_duration") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } startDateStr, err := RequiredParam[string](args, "start_date") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil } // Step 1: Create the iteration field. var createMutation struct { CreateProjectV2Field struct { ProjectV2Field struct { ProjectV2IterationField struct { ID string Name string } `graphql:"... on ProjectV2IterationField"` } `graphql:"projectV2Field"` } `graphql:"createProjectV2Field(input: $input)"` } createInput := githubv4.CreateProjectV2FieldInput{ ProjectID: githubv4.ID(projectID), DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), Name: githubv4.String(fieldName), } err = gqlClient.Mutate(ctx, &createMutation, createInput, nil) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil } fieldID := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID // Step 2: Configure the iteration field with start date and duration. var updateMutation struct { UpdateProjectV2Field struct { ProjectV2Field struct { ProjectV2IterationField struct { ID string Name string Configuration struct { Iterations []struct { ID string Title string StartDate string Duration int } } } `graphql:"... on ProjectV2IterationField"` } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` } parsedStartDate, err := time.Parse("2006-01-02", startDateStr) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil } // GitHub's ProjectV2IterationFieldConfigurationInput requires `iterations` as a // non-null array, so we always send at least an empty slice. When omitted, GitHub // generates a default set of iterations from start_date and duration. iterationsInput := []ProjectV2IterationFieldIterationInput{} if rawIterations, ok := args["iterations"].([]any); ok && len(rawIterations) > 0 { for i, item := range rawIterations { iterMap, ok := item.(map[string]any) if !ok { return utils.NewToolResultError(fmt.Sprintf("iterations[%d] must be an object", i)), nil, nil } iterTitle, ok := iterMap["title"].(string) if !ok || iterTitle == "" { return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: title is required and must be a non-empty string", i)), nil, nil } iterStartDate, ok := iterMap["start_date"].(string) if !ok || iterStartDate == "" { return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: start_date is required and must be a non-empty string", i)), nil, nil } iterDuration, ok := iterMap["duration"].(float64) if !ok || iterDuration <= 0 { return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: duration is required and must be a positive number", i)), nil, nil } parsedIterStartDate, err := time.Parse("2006-01-02", iterStartDate) if err != nil { return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: failed to parse start_date %q: %v", i, iterStartDate, err)), nil, nil } iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{ Title: githubv4.String(iterTitle), StartDate: githubv4.Date{Time: parsedIterStartDate}, Duration: githubv4.Int(int32(iterDuration)), //nolint:gosec // Iteration durations are small day counts }) } } configInput := ProjectV2IterationFieldConfigurationInput{ Duration: githubv4.Int(int32(duration)), //nolint:gosec // Iteration durations are small day counts StartDate: githubv4.Date{Time: parsedStartDate}, Iterations: iterationsInput, } updateInput := UpdateProjectV2FieldInput{ FieldID: githubv4.ID(fieldID), IterationConfiguration: &configInput, } err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil } field := updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField iterResults := make([]map[string]any, 0, len(field.Configuration.Iterations)) for _, iter := range field.Configuration.Iterations { iterResults = append(iterResults, map[string]any{ "id": iter.ID, "title": iter.Title, "start_date": iter.StartDate, "duration": iter.Duration, }) } result := map[string]any{ "id": field.ID, "name": field.Name, "configuration": map[string]any{ "iterations": iterResults, }, } return MarshalledTextResult(result), nil, nil } // getOwnerNodeID resolves a GitHub user or organization login to its GraphQL node ID. func getOwnerNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string) (string, error) { if ownerType == "org" { var query struct { Organization struct { ID string } `graphql:"organization(login: $login)"` } variables := map[string]any{ "login": githubv4.String(owner), } err := gqlClient.Query(ctx, &query, variables) return query.Organization.ID, err } var query struct { User struct { ID string } `graphql:"user(login: $login)"` } variables := map[string]any{ "login": githubv4.String(owner), } err := gqlClient.Query(ctx, &query, variables) return query.User.ID, err } // UpdateProjectV2FieldInput is the GraphQL input for the updateProjectV2Field mutation. // These types are defined locally because the pinned shurcooL/githubv4 release // (v0.0.0-20240727222349) does not yet expose them. Upstream master now generates // equivalent types, so this block can be removed when the dependency is next bumped. type UpdateProjectV2FieldInput struct { FieldID githubv4.ID `json:"fieldId"` IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"` } // ProjectV2IterationFieldConfigurationInput is the GraphQL input for configuring an iteration field. // GitHub's schema marks iterations as a required non-null list, so the field is not omitempty. type ProjectV2IterationFieldConfigurationInput struct { Duration githubv4.Int `json:"duration"` StartDate githubv4.Date `json:"startDate"` Iterations []ProjectV2IterationFieldIterationInput `json:"iterations"` } // ProjectV2IterationFieldIterationInput is the GraphQL input for a single iteration definition. type ProjectV2IterationFieldIterationInput struct { StartDate githubv4.Date `json:"startDate"` Duration githubv4.Int `json:"duration"` Title githubv4.String `json:"title"` } // detectOwnerType attempts to detect whether the project owner is a user or org. // It first asks GitHub for the account type, then falls back to project probes // for older or mocked clients where the account type is unavailable. func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) { user, resp, err := client.Users.Get(ctx, owner) if resp != nil && resp.Body != nil { _ = resp.Body.Close() } if err == nil && resp != nil && resp.StatusCode == http.StatusOK { switch user.GetType() { case "User": return "user", nil case "Organization": return "org", nil } } // Try user first (more common for personal projects) _, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) if err == nil && resp.StatusCode == http.StatusOK { _ = resp.Body.Close() return "user", nil } if resp != nil { _ = resp.Body.Close() } // If not found (404) or other error, try org _, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) if err == nil && resp.StatusCode == http.StatusOK { _ = resp.Body.Close() return "org", nil } if resp != nil { _ = resp.Body.Close() } return "", fmt.Errorf("could not determine owner type for %s with project %d: owner is neither a user nor an org with this project", owner, projectNumber) }