diff --git a/README.md b/README.md
index 2e896cea8..f3160577a 100644
--- a/README.md
+++ b/README.md
@@ -798,63 +798,75 @@ Options are:
Projects
-- **add_project_item** - Add project item
- - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required)
- - `item_type`: The item's type, either issue or pull_request. (string, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **delete_project_item** - Delete project item
- - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **get_project** - Get project
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number (number, required)
-
-- **get_project_field** - Get project field
- - `field_id`: The field's id. (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **get_project_item** - Get project item
- - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional)
- - `item_id`: The item's ID. (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
-
-- **list_project_fields** - List project fields
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `per_page`: Number of results per page (max 100, default: 30) (number, optional)
- - `project_number`: The project's number. (number, required)
-
-- **list_project_items** - List project items
- - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `per_page`: Number of results per page (max 100, default: 30) (number, optional)
- - `project_number`: The project's number. (number, required)
- - `query`: Search query to filter items (string, optional)
-
-- **list_projects** - List projects
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `per_page`: Number of results per page (max 100, default: 30) (number, optional)
- - `query`: Filter projects by a search query (matches title and description) (string, optional)
-
-- **update_project_item** - Update project item
- - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required)
- - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
- - `owner_type`: Owner type (string, required)
- - `project_number`: The project's number. (number, required)
- - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"} (object, required)
+- **project_read** - Read project information
+ - `after`: Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page. (string, optional)
+ - `before`: Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration. (string, optional)
+ - `field_id`: Field ID (required for get_project_field) (number, optional)
+ - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first. (string[], optional)
+ - `item_id`: Item ID (required for get_project_item) (number, optional)
+ - `method`: Read operation: get_project, list_projects, get_project_field, list_project_fields (call FIRST for IDs), get_project_item, list_project_items (use query + fields) (string, required)
+ - `owner`: GitHub username or org name (case-insensitive) (string, required)
+ - `owner_type`: Owner type: 'user' or 'org' (string, required)
+ - `per_page`: Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal. (number, optional)
+ - `project_number`: Project number (required for most methods) (number, optional)
+ - `query`: Query string (used ONLY with list_projects and list_project_items).
+
+Pattern Split:
+
+1. list_projects (project metadata only):
+ Scope: title text + open/closed state.
+ PERMITTED qualifiers: is:open, is:closed (state), simple title terms.
+ FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc.
+ Examples:
+ - roadmap is:open
+ - is:open feature planning
+ Reject & switch method if user intends items.
+
+2. list_project_items (issues / PRs inside ONE project):
+ MUST reflect user intent; strongly prefer explicit content type if narrowed:
+ - "open issues" → state:open is:issue
+ - "merged PRs" → state:merged is:pr
+ - "items updated this week" → updated:>@today-7d (omit type only if mixed desired)
+ - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user speciifies "items")
+ - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user speciifies "issues" or "PRs")
+ Query Construction Heuristics:
+ a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)
+ b. Map temporal phrases: "this week" → updated:>@today-7d
+ c. Map negations: "excluding wontfix" → -label:wontfix
+ d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence)
+
+Syntax Essentials (items):
+ AND: space-separated. (label:bug priority:high).
+ OR: comma inside one qualifier (label:bug,critical).
+ NOT: leading '-' (-label:wontfix).
+ Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5).
+ Quote multi-word values. (status:"In Review" team-name:"Backend Team").
+ Ranges: points:1..3, updated:<@today-30d.
+ Wildcards: title:*crash*, label:bug*.
+
+Common Qualifier Glossary (items):
+ is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE |
+ priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" |
+ updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label
+
+Pagination Mandate:
+ Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page.
+
+Recovery Guidance:
+ If user provides ambiguous request ("show project activity") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail.
+
+Never:
+ - Infer field IDs; fetch via list_project_fields.
+ - Drop 'fields' param on subsequent pages if field values are needed. (string, optional)
+
+- **project_write** - Modify project items
+ - `item_id`: For add: issue/PR ID. For update/delete: project item ID (not issue/PR ID) (number, required)
+ - `item_type`: Type to add: 'issue' or 'pull_request' (required for add_project_item) (string, optional)
+ - `method`: Write operation: add_project_item (needs item_type, item_id), update_project_item (needs item_id, updated_field), delete_project_item (needs item_id) (string, required)
+ - `owner`: GitHub username or org name (case-insensitive) (string, required)
+ - `owner_type`: Owner type: 'user' or 'org' (string, required)
+ - `project_number`: Project number (number, required)
+ - `updated_field`: Field update object (required for update_project_item). Format: {"id": 123456, "value": }. Value types: text=string, single-select=option ID (number), date=ISO string, number=number. Set value to null to clear. (object, optional)
diff --git a/pkg/github/__toolsnaps__/project_read.snap b/pkg/github/__toolsnaps__/project_read.snap
new file mode 100644
index 000000000..949d537cb
--- /dev/null
+++ b/pkg/github/__toolsnaps__/project_read.snap
@@ -0,0 +1,77 @@
+{
+ "annotations": {
+ "title": "Read project information",
+ "readOnlyHint": true
+ },
+ "description": "GitHub Projects V2 read operations.\n\nMethods: get_project | list_projects | list_project_fields | get_project_field | list_project_items | get_project_item\n\nKey distinctions:\n- list_projects: ONLY project metadata (title, open/closed). Never use item filters.\n- list_project_items: Issues/PRs inside ONE project. Prefer explicit is:issue or is:pr.\n\nField usage:\n- Call list_project_fields first to get IDs/types.\n- Use EXACT returned field names (case-insensitive match). Don't invent names or IDs.\n- Iteration synonyms (sprint/cycle/iteration) only if that field exists; map to the actual name (e.g. sprint:@current).\n- Only include filters for fields that exist and are relevant.\n\nItem query syntax:\nAND = space | OR = comma (label:bug,critical) | NOT = prefix - ( -label:wontfix )\nQuote multi-word values: status:\"In Review\" team-name:\"Backend Team\"\nHyphenate multi-word field names (story-points).\nRanges: points:1..3 dates:2025-01-01..2025-12-31\nComparisons: updated:\u003e@today-7d priority:\u003e1 points:\u003c=10\nWildcards: title:*crash* label:bug*\nTemporal shortcuts: @today @today-7d @today-30d\nIteration shortcuts: @current @next @previous\n\nPagination (mandatory):\nLoop while pageInfo.hasNextPage=true using after=nextCursor. Keep query, fields, per_page IDENTICAL each page.\n\nFields parameter:\nInclude field IDs on EVERY paginated list_project_items call if you need values. Omit → title only.\n\nCounting rules:\n- Count items array length after full pagination.\n- If multi-page: collect all pages, dedupe by item.id (fallback node_id) before totals.\n- Never count field objects, content, or nested arrays as separate items.\n- item.id = project item ID (for updates/deletes). item.content.id = underlying issue/PR ID.\n\nSummary vs list:\n- Summaries ONLY if user uses verbs: analyze | summarize | summary | report | overview | insights.\n- Listing verbs (list/show/get/fetch/display/enumerate) → just enumerate + total.\n\nExamples:\nlist_projects: \"roadmap is:open\"\nlist_project_items: state:open is:issue sprint:@current priority:high updated:\u003e@today-7d\n\nSelf-check before returning:\n☑ Paginated fully ☑ Dedupe by id/node_id ☑ Correct IDs used ☑ Field names valid ☑ Summary only if requested.\n\nReturn COMPLETE data or state what's missing (e.g. pages skipped).",
+ "inputSchema": {
+ "properties": {
+ "after": {
+ "description": "Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page.",
+ "type": "string"
+ },
+ "before": {
+ "description": "Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration.",
+ "type": "string"
+ },
+ "field_id": {
+ "description": "Field ID (required for get_project_field)",
+ "type": "number"
+ },
+ "fields": {
+ "description": "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "item_id": {
+ "description": "Item ID (required for get_project_item)",
+ "type": "number"
+ },
+ "method": {
+ "description": "Read operation: get_project, list_projects, get_project_field, list_project_fields (call FIRST for IDs), get_project_item, list_project_items (use query + fields)",
+ "enum": [
+ "get_project",
+ "list_projects",
+ "get_project_field",
+ "list_project_fields",
+ "get_project_item",
+ "list_project_items"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "GitHub username or org name (case-insensitive)",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type: 'user' or 'org'",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "per_page": {
+ "description": "Results per page (max 50). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.",
+ "type": "number"
+ },
+ "project_number": {
+ "description": "Project number (required for most methods)",
+ "type": "number"
+ },
+ "query": {
+ "description": "Query string (used ONLY with list_projects and list_project_items). \n\nPattern Split:\n\n1. list_projects (project metadata only):\n Scope: title text + open/closed state.\n PERMITTED qualifiers: is:open, is:closed (state), simple title terms.\n FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc.\n Examples:\n - roadmap is:open\n - is:open feature planning\n Reject \u0026 switch method if user intends items.\n\n2. list_project_items (issues / PRs inside ONE project):\n MUST reflect user intent; strongly prefer explicit content type if narrowed:\n - \"open issues\" → state:open is:issue\n - \"merged PRs\" → state:merged is:pr\n - \"items updated this week\" → updated:\u003e@today-7d (omit type only if mixed desired)\n - \"list all P1 priority items\" → priority:p1 (omit state if user wants all, omit type if user speciifies \"items\")\n - \"list all open P2 issues\" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user speciifies \"issues\" or \"PRs\")\n Query Construction Heuristics:\n a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)\n b. Map temporal phrases: \"this week\" → updated:\u003e@today-7d\n c. Map negations: \"excluding wontfix\" → -label:wontfix\n d. Map priority adjectives: \"high/sev1/p1\" → priority:high OR priority:p1 (choose based on field presence)\n\nSyntax Essentials (items):\n AND: space-separated. (label:bug priority:high).\n OR: comma inside one qualifier (label:bug,critical).\n NOT: leading '-' (-label:wontfix).\n Hyphenate multi-word field names. (team-name:\"Backend Team\", story-points:\u003e5).\n Quote multi-word values. (status:\"In Review\" team-name:\"Backend Team\").\n Ranges: points:1..3, updated:\u003c@today-30d.\n Wildcards: title:*crash*, label:bug*.\n\nCommon Qualifier Glossary (items):\n is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE |\n priority:p1|high | sprint-name:@current | team-name:\"Backend Team\" | parent-issue:\"org/repo#123\" |\n updated:\u003e@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label\n\nPagination Mandate:\n Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page.\n\nRecovery Guidance:\n If user provides ambiguous request (\"show project activity\") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail.\n\nNever:\n - Infer field IDs; fetch via list_project_fields.\n - Drop 'fields' param on subsequent pages if field values are needed.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "method",
+ "owner_type",
+ "owner"
+ ],
+ "type": "object"
+ },
+ "name": "project_read"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/project_write.snap b/pkg/github/__toolsnaps__/project_write.snap
new file mode 100644
index 000000000..c63db05c5
--- /dev/null
+++ b/pkg/github/__toolsnaps__/project_write.snap
@@ -0,0 +1,62 @@
+{
+ "annotations": {
+ "title": "Modify project items",
+ "readOnlyHint": false
+ },
+ "description": "Write operations for GitHub Projects.\n\nMethods: add_project_item (add issue/PR), update_project_item (update fields), delete_project_item (remove item).\nNote: item_id for add is the issue/PR ID; for update/delete it's the project item ID.",
+ "inputSchema": {
+ "properties": {
+ "item_id": {
+ "description": "For add: issue/PR ID. For update/delete: project item ID (not issue/PR ID)",
+ "type": "number"
+ },
+ "item_type": {
+ "description": "Type to add: 'issue' or 'pull_request' (required for add_project_item)",
+ "enum": [
+ "issue",
+ "pull_request"
+ ],
+ "type": "string"
+ },
+ "method": {
+ "description": "Write operation: add_project_item (needs item_type, item_id), update_project_item (needs item_id, updated_field), delete_project_item (needs item_id)",
+ "enum": [
+ "add_project_item",
+ "update_project_item",
+ "delete_project_item"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "GitHub username or org name (case-insensitive)",
+ "type": "string"
+ },
+ "owner_type": {
+ "description": "Owner type: 'user' or 'org'",
+ "enum": [
+ "user",
+ "org"
+ ],
+ "type": "string"
+ },
+ "project_number": {
+ "description": "Project number",
+ "type": "number"
+ },
+ "updated_field": {
+ "description": "Field update object (required for update_project_item). Format: {\"id\": 123456, \"value\": \u003cvalue\u003e}. Value types: text=string, single-select=option ID (number), date=ISO string, number=number. Set value to null to clear.",
+ "properties": {},
+ "type": "object"
+ }
+ },
+ "required": [
+ "method",
+ "owner_type",
+ "owner",
+ "project_number",
+ "item_id"
+ ],
+ "type": "object"
+ },
+ "name": "project_write"
+}
\ No newline at end of file
diff --git a/pkg/github/projects.go b/pkg/github/projects.go
index eee4bcb6c..b04fd4ff7 100644
--- a/pkg/github/projects.go
+++ b/pkg/github/projects.go
@@ -23,845 +23,786 @@ const (
ProjectAddFailedError = "failed to add a project item"
ProjectDeleteFailedError = "failed to delete a project item"
ProjectListFailedError = "failed to list project items"
+ MaxProjectsPerPage = 50
)
-func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("list_projects",
- mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")),
+// ProjectRead creates a tool to perform read operations on GitHub Projects V2.
+// Supports getting and listing projects, project fields, and project items.
+func ProjectRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("project_read",
+ mcp.WithDescription(t("TOOL_PROJECT_READ_DESCRIPTION", `GitHub Projects V2 read operations.
+
+Methods: get_project | list_projects | list_project_fields | get_project_field | list_project_items | get_project_item
+
+Key distinctions:
+- list_projects: ONLY project metadata (title, open/closed). Never use item filters.
+- list_project_items: Issues/PRs inside ONE project. Prefer explicit is:issue or is:pr.
+
+Field usage:
+- Call list_project_fields first to get IDs/types.
+- Use EXACT returned field names (case-insensitive match). Don't invent names or IDs.
+- Iteration synonyms (sprint/cycle/iteration) only if that field exists; map to the actual name (e.g. sprint:@current).
+- Only include filters for fields that exist and are relevant.
+
+Item query syntax:
+AND = space | OR = comma (label:bug,critical) | NOT = prefix - ( -label:wontfix )
+Quote multi-word values: status:"In Review" team-name:"Backend Team"
+Hyphenate multi-word field names (story-points).
+Ranges: points:1..3 dates:2025-01-01..2025-12-31
+Comparisons: updated:>@today-7d priority:>1 points:<=10
+Wildcards: title:*crash* label:bug*
+Temporal shortcuts: @today @today-7d @today-30d
+Iteration shortcuts: @current @next @previous
+
+Pagination (mandatory):
+Loop while pageInfo.hasNextPage=true using after=nextCursor. Keep query, fields, per_page IDENTICAL each page.
+
+Fields parameter:
+Include field IDs on EVERY paginated list_project_items call if you need values. Omit → title only.
+
+Counting rules:
+- Count items array length after full pagination.
+- If multi-page: collect all pages, dedupe by item.id (fallback node_id) before totals.
+- Never count field objects, content, or nested arrays as separate items.
+- item.id = project item ID (for updates/deletes). item.content.id = underlying issue/PR ID.
+
+Summary vs list:
+- Summaries ONLY if user uses verbs: analyze | summarize | summary | report | overview | insights.
+- Listing verbs (list/show/get/fetch/display/enumerate) → just enumerate + total.
+
+Examples:
+list_projects: "roadmap is:open"
+list_project_items: state:open is:issue sprint:@current priority:high updated:>@today-7d
+
+Self-check before returning:
+☑ Paginated fully ☑ Dedupe by id/node_id ☑ Correct IDs used ☑ Field names valid ☑ Summary only if requested.
+
+Return COMPLETE data or state what's missing (e.g. pages skipped).`)),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"),
+ Title: t("TOOL_PROJECT_READ_USER_TITLE", "Read project information"),
ReadOnlyHint: ToBoolPtr(true),
}),
- mcp.WithString("owner_type",
- mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org"),
- ),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
- ),
- mcp.WithString("query",
- mcp.Description("Filter projects by a search query (matches title and description)"),
- ),
- mcp.WithNumber("per_page",
- mcp.Description("Number of results per page (max 100, default: 30)"),
- ),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- queryStr, err := OptionalParam[string](req, "query")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- client, err := getClient(ctx)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- var resp *github.Response
- var projects []*github.ProjectV2
- minimalProjects := []MinimalProject{}
-
- opts := &github.ListProjectsOptions{
- ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{PerPage: perPage},
- Query: queryStr,
- }
-
- if ownerType == "org" {
- projects, resp, err = client.Projects.ListProjectsForOrg(ctx, owner, opts)
- } else {
- projects, resp, err = client.Projects.ListProjectsForUser(ctx, owner, opts)
- }
-
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to list projects",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- for _, project := range projects {
- minimalProjects = append(minimalProjects, *convertToMinimalProject(project))
- }
-
- 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 mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil
- }
- r, err := json.Marshal(minimalProjects)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_project",
- mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithNumber("project_number",
+ mcp.WithString("method",
mcp.Required(),
- mcp.Description("The project's number"),
+ mcp.Description(`Read operation: get_project, list_projects, get_project_field, list_project_fields (call FIRST for IDs), get_project_item, list_project_items (use query + fields)`),
+ mcp.Enum("get_project", "list_projects", "get_project_field", "list_project_fields", "get_project_item", "list_project_items"),
),
mcp.WithString("owner_type",
mcp.Required(),
- mcp.Description("Owner type"),
+ mcp.Description("Owner type: 'user' or 'org'"),
mcp.Enum("user", "org"),
),
mcp.WithString("owner",
mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
- ),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
-
- projectNumber, err := RequiredInt(req, "project_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- client, err := getClient(ctx)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- var resp *github.Response
- var project *github.ProjectV2
-
- if ownerType == "org" {
- project, resp, err = client.Projects.GetProjectForOrg(ctx, owner, projectNumber)
- } else {
- project, resp, err = client.Projects.GetProjectForUser(ctx, owner, projectNumber)
- }
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get project",
- resp,
- 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("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil
- }
-
- minimalProject := convertToMinimalProject(project)
- r, err := json.Marshal(minimalProject)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("list_project_fields",
- mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("owner_type",
- mcp.Required(),
- mcp.Description("Owner type"),
- mcp.Enum("user", "org")),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
+ mcp.Description("GitHub username or org name (case-insensitive)"),
),
mcp.WithNumber("project_number",
- mcp.Required(),
- mcp.Description("The project's number."),
- ),
- mcp.WithNumber("per_page",
- mcp.Description("Number of results per page (max 100, default: 30)"),
+ mcp.Description("Project number (required for most methods)"),
),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- projectNumber, err := RequiredInt(req, "project_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- client, err := getClient(ctx)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- var url string
- if ownerType == "org" {
- url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber)
- } else {
- url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber)
- }
- projectFields := []projectV2Field{}
-
- opts := paginationOptions{PerPage: perPage}
-
- url, err = addOptions(url, opts)
- if err != nil {
- return nil, fmt.Errorf("failed to add options to request: %w", err)
- }
-
- httpRequest, err := client.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := client.Do(ctx, httpRequest, &projectFields)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to list project fields",
- resp,
- 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("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to list project fields: %s", string(body))), nil
- }
- r, err := json.Marshal(projectFields)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_project_field",
- mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("owner_type",
- mcp.Required(),
- mcp.Description("Owner type"), mcp.Enum("user", "org")),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
- ),
- mcp.WithNumber("project_number",
- mcp.Required(),
- mcp.Description("The project's number.")),
mcp.WithNumber("field_id",
- mcp.Required(),
- mcp.Description("The field's id."),
+ mcp.Description("Field ID (required for get_project_field)"),
),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- projectNumber, err := RequiredInt(req, "project_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- fieldID, err := RequiredInt(req, "field_id")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- client, err := getClient(ctx)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- var url string
- if ownerType == "org" {
- url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
- } else {
- url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
- }
-
- projectField := projectV2Field{}
-
- httpRequest, err := client.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := client.Do(ctx, httpRequest, &projectField)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get project field",
- resp,
- 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("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to get project field: %s", string(body))), nil
- }
- r, err := json.Marshal(projectField)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("list_project_items",
- mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("owner_type",
- mcp.Required(),
- mcp.Description("Owner type"),
- mcp.Enum("user", "org"),
- ),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
- ),
- mcp.WithNumber("project_number", mcp.Required(),
- mcp.Description("The project's number."),
+ mcp.WithNumber("item_id",
+ mcp.Description("Item ID (required for get_project_item)"),
),
mcp.WithString("query",
- mcp.Description("Search query to filter items"),
+ mcp.Description(`Query string (used ONLY with list_projects and list_project_items).
+
+Pattern Split:
+
+1. list_projects (project metadata only):
+ Scope: title text + open/closed state.
+ PERMITTED qualifiers: is:open, is:closed (state), simple title terms.
+ FORBIDDEN: is:issue, is:pr, assignee:, label:, status:, sprint-name:, parent-issue:, team-name:, priority:, etc.
+ Examples:
+ - roadmap is:open
+ - is:open feature planning
+ Reject & switch method if user intends items.
+
+2. list_project_items (issues / PRs inside ONE project):
+ MUST reflect user intent; strongly prefer explicit content type if narrowed:
+ - "open issues" → state:open is:issue
+ - "merged PRs" → state:merged is:pr
+ - "items updated this week" → updated:>@today-7d (omit type only if mixed desired)
+ - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user speciifies "items")
+ - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user speciifies "issues" or "PRs")
+ Query Construction Heuristics:
+ a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity)
+ b. Map temporal phrases: "this week" → updated:>@today-7d
+ c. Map negations: "excluding wontfix" → -label:wontfix
+ d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence)
+
+Syntax Essentials (items):
+ AND: space-separated. (label:bug priority:high).
+ OR: comma inside one qualifier (label:bug,critical).
+ NOT: leading '-' (-label:wontfix).
+ Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5).
+ Quote multi-word values. (status:"In Review" team-name:"Backend Team").
+ Ranges: points:1..3, updated:<@today-30d.
+ Wildcards: title:*crash*, label:bug*.
+
+Common Qualifier Glossary (items):
+ is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE |
+ priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" |
+ updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label
+
+Pagination Mandate:
+ Do not analyze until ALL pages fetched (loop while pageInfo.hasNextPage=true). Always reuse identical query, fields, per_page.
+
+Recovery Guidance:
+ If user provides ambiguous request ("show project activity") → ask clarification OR return mixed set (omit is:issue/is:pr). If user mixes project + item qualifiers in one phrase → split: run list_projects for discovery, then list_project_items for detail.
+
+Never:
+ - Infer field IDs; fetch via list_project_fields.
+ - Drop 'fields' param on subsequent pages if field values are needed.`),
),
mcp.WithNumber("per_page",
- mcp.Description("Number of results per page (max 100, default: 30)"),
- ),
- mcp.WithArray("fields",
- mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."),
- mcp.WithStringItems(),
- ),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- projectNumber, err := RequiredInt(req, "project_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- queryStr, err := OptionalParam[string](req, "query")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- fields, err := OptionalStringArrayParam(req, "fields")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- client, err := getClient(ctx)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- var url string
- if ownerType == "org" {
- url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
- } else {
- url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
- }
- projectItems := []projectV2Item{}
-
- opts := listProjectItemsOptions{
- paginationOptions: paginationOptions{PerPage: perPage},
- filterQueryOptions: filterQueryOptions{Query: queryStr},
- fieldSelectionOptions: fieldSelectionOptions{Fields: fields},
- }
-
- url, err = addOptions(url, opts)
- if err != nil {
- return nil, fmt.Errorf("failed to add options to request: %w", err)
- }
-
- httpRequest, err := client.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := client.Do(ctx, httpRequest, &projectItems)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- ProjectListFailedError,
- resp,
- 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("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectListFailedError, string(body))), nil
- }
-
- r, err := json.Marshal(projectItems)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_project_item",
- mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("owner_type",
- mcp.Required(),
- mcp.Description("Owner type"),
- mcp.Enum("user", "org"),
+ mcp.Description(fmt.Sprintf("Results per page (max %d). Keep constant across paginated requests; changing mid-sequence can complicate page traversal.", MaxProjectsPerPage)),
),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
+ mcp.WithString("after",
+ mcp.Description("Forward pagination cursor. Use when the previous response's pageInfo.hasNextPage=true. Supply pageInfo.nextCursor as 'after' and immediately request the next page. LOOP UNTIL pageInfo.hasNextPage=false (don't stop early). Keep query, fields, and per_page identical for every page."),
),
- mcp.WithNumber("project_number",
- mcp.Required(),
- mcp.Description("The project's number."),
- ),
- mcp.WithNumber("item_id",
- mcp.Required(),
- mcp.Description("The item's ID."),
+ mcp.WithString("before",
+ mcp.Description("Backward pagination cursor (rare): supply to move to the preceding page using pageInfo.prevCursor. Not needed for normal forward iteration."),
),
mcp.WithArray("fields",
- mcp.Description("Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included."),
+ mcp.Description("Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Get IDs from list_project_fields first."),
mcp.WithStringItems(),
),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- projectNumber, err := RequiredInt(req, "project_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- itemID, err := RequiredInt(req, "item_id")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- fields, err := OptionalStringArrayParam(req, "fields")
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ method, err := RequiredParam[string](request, "method")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- client, err := getClient(ctx)
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- var url string
- if ownerType == "org" {
- url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
- } else {
- url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
- }
-
- opts := fieldSelectionOptions{}
-
- if len(fields) > 0 {
- opts.Fields = fields
- }
-
- url, err = addOptions(url, opts)
+ ownerType, err := RequiredParam[string](request, "owner_type")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- projectItem := projectV2Item{}
-
- httpRequest, err := client.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := client.Do(ctx, httpRequest, &projectItem)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get project item",
- resp,
- 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("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to get project item: %s", string(body))), nil
- }
- r, err := json.Marshal(projectItem)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("add_project_item",
- mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"),
- ReadOnlyHint: ToBoolPtr(false),
- }),
- mcp.WithString("owner_type",
- mcp.Required(),
- mcp.Description("Owner type"), mcp.Enum("user", "org"),
- ),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
- ),
- mcp.WithNumber("project_number",
- mcp.Required(),
- mcp.Description("The project's number."),
- ),
- mcp.WithString("item_type",
- mcp.Required(),
- mcp.Description("The item's type, either issue or pull_request."),
- mcp.Enum("issue", "pull_request"),
- ),
- mcp.WithNumber("item_id",
- mcp.Required(),
- mcp.Description("The numeric ID of the issue or pull request to add to the project."),
- ),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- projectNumber, err := RequiredInt(req, "project_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- itemID, err := RequiredInt(req, "item_id")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- itemType, err := RequiredParam[string](req, "item_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- if itemType != "issue" && itemType != "pull_request" {
- return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil
- }
-
client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- var projectsURL string
- if ownerType == "org" {
- projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
- } else {
- projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
- }
-
- newItem := &newProjectItem{
- ID: int64(itemID),
- Type: toNewProjectType(itemType),
- }
- httpRequest, err := client.NewRequest("POST", projectsURL, newItem)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- addedItem := projectV2Item{}
+ switch method {
+ case "get_project":
+ projectNumber, err := RequiredInt(request, "project_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return getProject(ctx, client, owner, ownerType, projectNumber)
- resp, err := client.Do(ctx, httpRequest, &addedItem)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- ProjectAddFailedError,
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+ case "list_projects":
+ queryStr, err := OptionalParam[string](request, "query")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- if resp.StatusCode != http.StatusCreated {
- body, err := io.ReadAll(resp.Body)
+ pagination, err := extractPaginationOptions(request)
if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
+ return mcp.NewToolResultError(err.Error()), nil
}
- return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectAddFailedError, string(body))), nil
- }
- r, err := json.Marshal(addedItem)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
- return mcp.NewToolResultText(string(r)), nil
- }
-}
+ return listProjects(ctx, client, owner, ownerType, queryStr, pagination)
-func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("update_project_item",
- mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"),
- ReadOnlyHint: ToBoolPtr(false),
- }),
- mcp.WithString("owner_type",
- mcp.Required(), mcp.Description("Owner type"),
- mcp.Enum("user", "org"),
- ),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
- ),
- mcp.WithNumber("project_number",
- mcp.Required(),
- mcp.Description("The project's number."),
- ),
- mcp.WithNumber("item_id",
- mcp.Required(),
- mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."),
- ),
- mcp.WithObject("updated_field",
- mcp.Required(),
- mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"),
- ),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- ownerType, err := RequiredParam[string](req, "owner_type")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- projectNumber, err := RequiredInt(req, "project_number")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- itemID, err := RequiredInt(req, "item_id")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
+ case "get_project_field":
+ projectNumber, err := RequiredInt(request, "project_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ fieldID, err := RequiredInt(request, "field_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID)
- rawUpdatedField, exists := req.GetArguments()["updated_field"]
- if !exists {
- return mcp.NewToolResultError("missing required parameter: updated_field"), nil
- }
+ case "list_project_fields":
+ projectNumber, err := RequiredInt(request, "project_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- fieldValue, ok := rawUpdatedField.(map[string]any)
- if !ok || fieldValue == nil {
- return mcp.NewToolResultError("field_value must be an object"), nil
- }
+ pagination, err := extractPaginationOptions(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- updatePayload, err := buildUpdateProjectItem(fieldValue)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
+ return listProjectFields(ctx, client, owner, ownerType, projectNumber, pagination)
- client, err := getClient(ctx)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
+ case "get_project_item":
+ projectNumber, err := RequiredInt(request, "project_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ itemID, err := RequiredInt(request, "item_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ fields, err := OptionalStringArrayParam(request, "fields")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
- var projectsURL string
- if ownerType == "org" {
- projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
- } else {
- projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
- }
- httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{
- Fields: []updateProjectItem{*updatePayload},
- })
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- updatedItem := projectV2Item{}
+ case "list_project_items":
+ projectNumber, err := RequiredInt(request, "project_number")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- resp, err := client.Do(ctx, httpRequest, &updatedItem)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- ProjectUpdateFailedError,
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+ queryStr, err := OptionalParam[string](request, "query")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- if resp.StatusCode != http.StatusOK {
- body, err := io.ReadAll(resp.Body)
+ pagination, err := extractPaginationOptions(request)
if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
+ return mcp.NewToolResultError(err.Error()), nil
}
- return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectUpdateFailedError, string(body))), nil
- }
- r, err := json.Marshal(updatedItem)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
- return mcp.NewToolResultText(string(r)), nil
+ fields, err := OptionalStringArrayParam(request, "fields")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ return listProjectItems(ctx, client, owner, ownerType, projectNumber, queryStr, pagination, fields)
+
+ default:
+ return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil
+ }
}
}
-func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("delete_project_item",
- mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")),
+// ProjectWrite creates a tool to perform write operations on GitHub Projects V2.
+// Supports adding, updating, and deleting project items.
+func ProjectWrite(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("project_write",
+ mcp.WithDescription(t("TOOL_PROJECT_WRITE_DESCRIPTION", `Write operations for GitHub Projects.
+
+Methods: add_project_item (add issue/PR), update_project_item (update fields), delete_project_item (remove item).
+Note: item_id for add is the issue/PR ID; for update/delete it's the project item ID.`)),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"),
+ Title: t("TOOL_PROJECT_WRITE_USER_TITLE", "Modify project items"),
ReadOnlyHint: ToBoolPtr(false),
}),
+ mcp.WithString("method",
+ mcp.Required(),
+ mcp.Description(`Write operation: add_project_item (needs item_type, item_id), update_project_item (needs item_id, updated_field), delete_project_item (needs item_id)`),
+ mcp.Enum("add_project_item", "update_project_item", "delete_project_item"),
+ ),
mcp.WithString("owner_type",
mcp.Required(),
- mcp.Description("Owner type"),
+ mcp.Description("Owner type: 'user' or 'org'"),
mcp.Enum("user", "org"),
),
mcp.WithString("owner",
mcp.Required(),
- mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive."),
+ mcp.Description("GitHub username or org name (case-insensitive)"),
),
mcp.WithNumber("project_number",
mcp.Required(),
- mcp.Description("The project's number."),
+ mcp.Description("Project number"),
),
mcp.WithNumber("item_id",
mcp.Required(),
- mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID)."),
+ mcp.Description("For add: issue/PR ID. For update/delete: project item ID (not issue/PR ID)"),
+ ),
+ mcp.WithString("item_type",
+ mcp.Description("Type to add: 'issue' or 'pull_request' (required for add_project_item)"),
+ mcp.Enum("issue", "pull_request"),
+ ),
+ mcp.WithObject("updated_field",
+ mcp.Description("Field update object (required for update_project_item). Format: {\"id\": 123456, \"value\": }. Value types: text=string, single-select=option ID (number), date=ISO string, number=number. Set value to null to clear."),
),
- ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](req, "owner")
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ method, err := RequiredParam[string](request, "method")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- ownerType, err := RequiredParam[string](req, "owner_type")
+
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- projectNumber, err := RequiredInt(req, "project_number")
+
+ ownerType, err := RequiredParam[string](request, "owner_type")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- itemID, err := RequiredInt(req, "item_id")
+
+ projectNumber, err := RequiredInt(request, "project_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- client, err := getClient(ctx)
+
+ itemID, err := RequiredInt(request, "item_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- var projectsURL string
- if ownerType == "org" {
- projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
- } else {
- projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
- }
-
- httpRequest, err := client.NewRequest("DELETE", projectsURL, nil)
+ client, err := getClient(ctx)
if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
+ return mcp.NewToolResultError(err.Error()), nil
}
- resp, err := client.Do(ctx, httpRequest, nil)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- ProjectDeleteFailedError,
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+ switch method {
+ case "add_project_item":
+ itemType, err := RequiredParam[string](request, "item_type")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ if itemType != "issue" && itemType != "pull_request" {
+ return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil
+ }
+ return addProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, itemType)
- if resp.StatusCode != http.StatusNoContent {
- body, err := io.ReadAll(resp.Body)
+ case "update_project_item":
+ rawUpdatedField, exists := request.GetArguments()["updated_field"]
+ if !exists {
+ return mcp.NewToolResultError("missing required parameter: updated_field"), nil
+ }
+ fieldValue, ok := rawUpdatedField.(map[string]any)
+ if !ok || fieldValue == nil {
+ return mcp.NewToolResultError("updated_field must be an object"), nil
+ }
+ updatePayload, err := buildUpdateProjectItem(fieldValue)
if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
+ return mcp.NewToolResultError(err.Error()), nil
}
- return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectDeleteFailedError, string(body))), nil
+ return updateProjectItemHelper(ctx, client, owner, ownerType, projectNumber, itemID, updatePayload)
+
+ case "delete_project_item":
+ return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID)
+
+ default:
+ return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil
+ }
+ }
+}
+
+func getProject(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int) (*mcp.CallToolResult, error) {
+ var project *github.ProjectV2
+ var resp *github.Response
+ var err error
+
+ if ownerType == "org" {
+ project, resp, err = client.Projects.GetProjectForOrg(ctx, owner, projectNumber)
+ } else {
+ project, resp, err = client.Projects.GetProjectForUser(ctx, owner, projectNumber)
+ }
+
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get project",
+ resp,
+ err,
+ ), nil
+ }
+
+ minimalProject := convertToMinimalProject(project)
+ r, err := json.Marshal(minimalProject)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func listProjects(ctx context.Context, client *github.Client, owner string, ownerType string, queryStr string, pagination paginationOptions) (*mcp.CallToolResult, error) {
+ opts := &github.ListProjectsOptions{
+ ListProjectsPaginationOptions: github.ListProjectsPaginationOptions{
+ PerPage: pagination.PerPage,
+ After: pagination.After,
+ Before: pagination.Before,
+ },
+ Query: queryStr,
+ }
+
+ var projects []*github.ProjectV2
+ var resp *github.Response
+ var err error
+
+ if ownerType == "org" {
+ projects, resp, err = client.Projects.ListProjectsForOrg(ctx, owner, opts)
+ } else {
+ projects, resp, err = client.Projects.ListProjectsForUser(ctx, owner, opts)
+ }
+
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list projects",
+ resp,
+ err,
+ ), nil
+ }
+
+ minimalProjects := []MinimalProject{}
+ for _, project := range projects {
+ minimalProjects = append(minimalProjects, *convertToMinimalProject(project))
+ }
+
+ response := map[string]any{
+ "projects": minimalProjects,
+ "pageInfo": buildPageInfo(resp),
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func getProjectField(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, fieldID int) (*mcp.CallToolResult, error) {
+ var url string
+ if ownerType == "org" {
+ url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
+ } else {
+ url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
+ }
+
+ var projectField projectV2Field
+
+ httpRequest, err := client.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := client.Do(ctx, httpRequest, &projectField)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get project field",
+ resp,
+ 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("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get project field: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(projectField)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func listProjectFields(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, pagination paginationOptions) (*mcp.CallToolResult, error) {
+ var url string
+ if ownerType == "org" {
+ url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber)
+ } else {
+ url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber)
+ }
+
+ type listProjectFieldsOptions struct {
+ paginationOptions
+ }
+
+ opts := listProjectFieldsOptions{
+ paginationOptions: pagination,
+ }
+
+ url, err := addOptions(url, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to add options to request: %w", err)
+ }
+
+ httpRequest, err := client.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ var projectFields []*projectV2Field
+ resp, err := client.Do(ctx, httpRequest, &projectFields)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list project fields",
+ resp,
+ 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("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list project fields: %s", string(body))), nil
+ }
+
+ filteredFields := filterSpecialTypes(projectFields)
+
+ response := map[string]any{
+ "fields": filteredFields,
+ "pageInfo": buildPageInfo(resp),
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func getProjectItem(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int, fields []string) (*mcp.CallToolResult, error) {
+ var url string
+ if ownerType == "org" {
+ url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ } else {
+ url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ }
+
+ opts := fieldSelectionOptions{}
+
+ if len(fields) > 0 {
+ opts.Fields = strings.Join(fields, ",")
+ }
+
+ url, err := addOptions(url, opts)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ projectItem := projectV2Item{}
+
+ httpRequest, err := client.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := client.Do(ctx, httpRequest, &projectItem)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get project item",
+ resp,
+ 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("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get project item: %s", string(body))), nil
+ }
+
+ if len(projectItem.Fields) > 0 {
+ projectItem.Fields = filterSpecialTypes(projectItem.Fields)
+ }
+
+ r, err := json.Marshal(projectItem)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func listProjectItems(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, queryStr string, pagination paginationOptions, fields []string) (*mcp.CallToolResult, error) {
+ var url string
+ if ownerType == "org" {
+ url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
+ } else {
+ url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
+ }
+ projectItems := []projectV2Item{}
+
+ opts := listProjectItemsOptions{
+ paginationOptions: pagination,
+ filterQueryOptions: filterQueryOptions{Query: queryStr},
+ fieldSelectionOptions: fieldSelectionOptions{Fields: strings.Join(fields, ",")},
+ }
+
+ url, err := addOptions(url, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to add options to request: %w", err)
+ }
+
+ fmt.Println("URL for listProjectItems:")
+ fmt.Println(url)
+
+ httpRequest, err := client.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := client.Do(ctx, httpRequest, &projectItems)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ ProjectListFailedError,
+ resp,
+ 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("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectListFailedError, string(body))), nil
+ }
+
+ if len(projectItems) > 0 {
+ for i := range projectItems {
+ if len(projectItems[i].Fields) > 0 {
+ projectItems[i].Fields = filterSpecialTypes(projectItems[i].Fields)
}
- return mcp.NewToolResultText("project item successfully deleted"), nil
}
+ }
+
+ response := map[string]any{
+ "items": projectItems,
+ "pageInfo": buildPageInfo(resp),
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+// Helper functions for ProjectWrite
+
+func addProjectItem(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int, itemType string) (*mcp.CallToolResult, error) {
+ var projectsURL string
+ if ownerType == "org" {
+ projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber)
+ } else {
+ projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber)
+ }
+
+ newItem := &newProjectItem{
+ ID: int64(itemID),
+ Type: toNewProjectType(itemType),
+ }
+ httpRequest, err := client.NewRequest("POST", projectsURL, newItem)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ addedItem := projectV2Item{}
+
+ resp, err := client.Do(ctx, httpRequest, &addedItem)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ ProjectAddFailedError,
+ resp,
+ 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 mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectAddFailedError, string(body))), nil
+ }
+ r, err := json.Marshal(addedItem)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func updateProjectItemHelper(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int, updatePayload *updateProjectItem) (*mcp.CallToolResult, error) {
+ var projectsURL string
+ if ownerType == "org" {
+ projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ } else {
+ projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ }
+ httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemPayload{
+ Fields: []updateProjectItem{*updatePayload},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ updatedItem := projectV2Item{}
+
+ resp, err := client.Do(ctx, httpRequest, &updatedItem)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ ProjectUpdateFailedError,
+ resp,
+ 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("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectUpdateFailedError, string(body))), nil
+ }
+ r, err := json.Marshal(updatedItem)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func deleteProjectItem(ctx context.Context, client *github.Client, owner string, ownerType string, projectNumber int, itemID int) (*mcp.CallToolResult, error) {
+ var projectsURL string
+ if ownerType == "org" {
+ projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ } else {
+ projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID)
+ }
+
+ httpRequest, err := client.NewRequest("DELETE", projectsURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ resp, err := client.Do(ctx, httpRequest, nil)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ ProjectDeleteFailedError,
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusNoContent {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectDeleteFailedError, string(body))), nil
+ }
+ return mcp.NewToolResultText("project item successfully deleted"), nil
}
type newProjectItem struct {
@@ -879,21 +820,36 @@ type updateProjectItem struct {
}
type projectV2Field struct {
- ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
- NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.
- Name string `json:"name,omitempty"` // The display name of the field.
- DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select").
- URL string `json:"url,omitempty"` // The API URL for this field.
- Options []*any `json:"options,omitempty"` // Available options for single_select and multi_select fields.
- CreatedAt *github.Timestamp `json:"created_at,omitempty"` // The time when this field was created.
- UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated.
+ ID *int64 `json:"id,omitempty"`
+ NodeID string `json:"node_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ DataType string `json:"data_type,omitempty"`
+ URL string `json:"url,omitempty"`
+ Options []*any `json:"options,omitempty"` // For single-select fields
+ Configuration *any `json:"configuration,omitempty"` // For iteration fields
+ CreatedAt *github.Timestamp `json:"created_at,omitempty"`
+ UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
+}
+
+func (f *projectV2Field) getDataType() string {
+ if f == nil {
+ return ""
+ }
+ return strings.ToLower(f.DataType)
}
type projectV2ItemFieldValue struct {
- ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
- Name string `json:"name,omitempty"` // The display name of the field.
- DataType string `json:"data_type,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select").
- Value interface{} `json:"value,omitempty"` // The value of the field for a specific project item.
+ ID *int64 `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+ DataType string `json:"data_type,omitempty"`
+ Value any `json:"value,omitempty"`
+}
+
+func (v *projectV2ItemFieldValue) getDataType() string {
+ if v == nil {
+ return ""
+ }
+ return strings.ToLower(v.DataType)
}
type projectV2Item struct {
@@ -913,21 +869,42 @@ type projectV2Item struct {
}
type projectV2ItemContent struct {
- Body *string `json:"body,omitempty"`
- ClosedAt *github.Timestamp `json:"closed_at,omitempty"`
- CreatedAt *github.Timestamp `json:"created_at,omitempty"`
- ID *int64 `json:"id,omitempty"`
- Number *int `json:"number,omitempty"`
- Repository MinimalRepository `json:"repository,omitempty"`
- State *string `json:"state,omitempty"`
- StateReason *string `json:"stateReason,omitempty"`
- Title *string `json:"title,omitempty"`
- UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
- URL *string `json:"url,omitempty"`
+ Body *string `json:"body,omitempty"`
+ ClosedAt *github.Timestamp `json:"closed_at,omitempty"`
+ CreatedAt *github.Timestamp `json:"created_at,omitempty"`
+ ID *int64 `json:"id,omitempty"`
+ Number *int `json:"number,omitempty"`
+ Repository *projectV2ItemContentRepository `json:"repository,omitempty"`
+ State *string `json:"state,omitempty"`
+ StateReason *string `json:"stateReason,omitempty"`
+ Title *string `json:"title,omitempty"`
+ UpdatedAt *github.Timestamp `json:"updated_at,omitempty"`
+ URL *string `json:"url,omitempty"`
+ Type *any `json:"type,omitempty"`
+ Labels []*any `json:"labels,omitempty"`
+ Assignees []*MinimalUser `json:"assignees,omitempty"`
+ Milestone *any `json:"milestone,omitempty"`
+}
+
+type projectV2ItemContentRepository struct {
+ ID *int64 `json:"id"`
+ Name *string `json:"name"`
+ FullName *string `json:"full_name"`
+ Description *string `json:"description,omitempty"`
+ HTMLURL *string `json:"html_url"`
+}
+
+type pageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ HasPreviousPage bool `json:"hasPreviousPage"`
+ NextCursor string `json:"nextCursor,omitempty"`
+ PrevCursor string `json:"prevCursor,omitempty"`
}
type paginationOptions struct {
- PerPage int `url:"per_page,omitempty"`
+ PerPage int `url:"per_page,omitempty"`
+ After string `url:"after,omitempty"`
+ Before string `url:"before,omitempty"`
}
type filterQueryOptions struct {
@@ -935,9 +912,7 @@ type filterQueryOptions struct {
}
type fieldSelectionOptions struct {
- // Specific list of field IDs to include in the response. If not provided, only the title field is included.
- // Example: fields=102589,985201,169875 or fields[]=102589&fields[]=985201&fields[]=169875
- Fields []string `url:"fields,omitempty"`
+ Fields string `url:"fields,omitempty"`
}
type listProjectItemsOptions struct {
@@ -981,6 +956,81 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) {
return payload, nil
}
+func buildPageInfo(resp *github.Response) pageInfo {
+ return pageInfo{
+ HasNextPage: resp.After != "",
+ HasPreviousPage: resp.Before != "",
+ NextCursor: resp.After,
+ PrevCursor: resp.Before,
+ }
+}
+
+func extractPaginationOptions(request mcp.CallToolRequest) (paginationOptions, error) {
+ perPage, err := OptionalIntParamWithDefault(request, "per_page", MaxProjectsPerPage)
+ if err != nil {
+ return paginationOptions{}, err
+ }
+ if perPage > MaxProjectsPerPage {
+ perPage = MaxProjectsPerPage
+ }
+
+ after, err := OptionalParam[string](request, "after")
+ if err != nil {
+ return paginationOptions{}, err
+ }
+
+ before, err := OptionalParam[string](request, "before")
+ if err != nil {
+ return paginationOptions{}, err
+ }
+
+ return paginationOptions{
+ PerPage: perPage,
+ After: after,
+ Before: before,
+ }, nil
+}
+
+// "special" data types that are present in the project item's content object.
+var specialFieldDataTypes = map[string]struct{}{
+ "assignees": {},
+ "labels": {},
+ "linked_pull_requests": {},
+ "milestone": {},
+ "parent_issue": {},
+ "repository": {},
+ "reviewers": {},
+ "sub_issues_progress": {},
+ "title": {},
+}
+
+// filterSpecialTypes returns a new slice containing only those field definitions
+// or field values whose DataType is NOT in the specialFieldDataTypes set. The
+// input must be a slice whose element type implements getDataType() string.
+//
+// Applicable to:
+//
+// []*projectV2Field
+// []*projectV2ItemFieldValue
+//
+// Example:
+//
+// filtered := filterSpecialTypes(fields)
+func filterSpecialTypes[T interface{ getDataType() string }](fields []T) []T {
+ if len(fields) == 0 {
+ return fields
+ }
+ out := make([]T, 0, len(fields))
+ for _, f := range fields {
+ dt := f.getDataType()
+ if _, isSpecial := specialFieldDataTypes[dt]; isSpecial {
+ continue
+ }
+ out = append(out, f)
+ }
+ return out
+}
+
// addOptions adds the parameters in opts as URL query parameters to s. opts
// must be a struct whose fields may contain "url" tags.
func addOptions(s string, opts any) (string, error) {
@@ -999,16 +1049,18 @@ func addOptions(s string, opts any) (string, error) {
return s, err
}
+ fmt.Println("URL for listProjectItems:")
+ fmt.Println(qs.Encode())
+
u.RawQuery = qs.Encode()
return u.String(), nil
}
func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) {
return mcp.NewPrompt("ManageProjectItems",
- mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Interactive guide for managing GitHub Projects V2, including discovery, field management, querying, and updates.")),
+ mcp.WithPromptDescription(t("PROMPT_MANAGE_PROJECT_ITEMS_DESCRIPTION", "Guide for GitHub Projects V2: discovery, fields, querying, updates.")),
mcp.WithArgument("owner", mcp.ArgumentDescription("The owner of the project (user or organization name)"), mcp.RequiredArgument()),
mcp.WithArgument("owner_type", mcp.ArgumentDescription("Type of owner: 'user' or 'org'"), mcp.RequiredArgument()),
- mcp.WithArgument("task", mcp.ArgumentDescription("Optional: specific task to focus on (e.g., 'discover_projects', 'update_items', 'create_reports')")),
), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
owner := request.Params.Arguments["owner"]
ownerType := request.Params.Arguments["owner_type"]
@@ -1021,161 +1073,134 @@ func ManageProjectItemsPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr
messages := []mcp.PromptMessage{
{
Role: "system",
- Content: mcp.NewTextContent("You are a GitHub Projects V2 management assistant. Your expertise includes:\n\n" +
- "**Core Capabilities:**\n" +
- "- Project discovery and field analysis\n" +
- "- Item querying with advanced filters\n" +
- "- Field value updates and management\n" +
- "- Progress reporting and insights\n\n" +
- "**Key Rules:**\n" +
- "- ALWAYS use the 'query' parameter in **list_project_items** to filter results effectively\n" +
- "- ALWAYS include 'fields' parameter with specific field IDs to retrieve field values\n" +
- "- Use proper field IDs (not names) when updating items\n" +
- "- Provide step-by-step workflows with concrete examples\n\n" +
- "**Understanding Project Items:**\n" +
- "- Project items reference underlying content (issues or pull requests)\n" +
- "- Project tools provide: project fields, item metadata, and basic content info\n" +
- "- For detailed information about an issue or pull request (comments, events, etc.), use issue/PR specific tools\n" +
- "- The 'content' field in project items includes: repository, issue/PR number, title, state\n" +
- "- Use this info to fetch full details: **get_issue**, **list_comments**, **list_issue_events**\n\n" +
- "**Available Tools:**\n" +
- "- **list_projects**: Discover available projects\n" +
- "- **get_project**: Get detailed project information\n" +
- "- **list_project_fields**: Get field definitions and IDs\n" +
- "- **list_project_items**: Query items with filters and field selection\n" +
- "- **get_project_item**: Get specific item details\n" +
- "- **add_project_item**: Add issues/PRs to projects\n" +
- "- **update_project_item**: Update field values\n" +
- "- **delete_project_item**: Remove items from projects"),
+ Content: mcp.NewTextContent(`System guide: GitHub Projects V2.
+Goal: Pick correct method, fetch COMPLETE data (no early pagination stop), apply accurate filters, and count items correctly.
+
+Method quick map:
+list_projects (metadata only) | get_project (single) | list_project_fields (define IDs) | get_project_field | list_project_items (issues/PRs) | get_project_item | project_write (mutations) | issue_read / pull_request_read (deep details)
+
+Core rules:
+- list_projects: NEVER include item-level filters.
+- Before filtering on custom fields call list_project_fields.
+- Always paginate until pageInfo.hasNextPage=false.
+- Keep query, fields, per_page identical across pages.
+- Include fields IDs on every list_project_items page if you need values.
+- Prefer explicit is:issue / is:pr unless mixed set requested.
+- Only summarize if verbs like analyze / summarize / report / overview / insights appear; otherwise enumerate.
+
+Field resolution:
+- Use exact returned field names; don't invent.
+- Iteration synonyms map to actual existing name (Sprint → sprint:@current, etc.). If none exist, omit.
+- Only add filters for fields that exist and matter to the user goal.
+
+Query syntax essentials:
+AND space | OR comma | NOT prefix - | quote multi-word values | hyphenate names | ranges points:1..5 | comparisons updated:>@today-7d priority:>1 | wildcards title:*crash*
+
+Pagination pattern:
+Call list_project_items → if hasNextPage true, repeat with after=nextCursor → stop only when false → then count/deduplicate.
+
+Counting:
+- Items array length after full pagination (dedupe by item.id or node_id).
+- Never count fields array, content, assignees, labels as separate items.
+- item.id = project item identifier; content.id = underlying issue/PR id.
+
+Edge handling:
+Empty pages → total=0 still return pageInfo.
+Duplicates → keep first for totals.
+Missing field values → null/omit, never fabricate.
+
+Self-check: paginated? deduped? correct IDs? field names valid? summary allowed?`),
},
{
Role: "user",
- Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s\n\n"+
- "Help me get started with project management tasks.",
+ Content: mcp.NewTextContent(fmt.Sprintf("I want to work with GitHub Projects for %s (owner_type: %s).%s",
owner,
ownerType,
func() string {
if task != "" {
- return fmt.Sprintf(" I'm specifically interested in: %s.", task)
+ return fmt.Sprintf(" Focus: %s.", task)
}
return ""
}())),
},
+ {
+ Role: "assistant",
+ Content: mcp.NewTextContent("Start by listing projects: project_read method=\"list_projects\"."),
+ },
+ {
+ Role: "user",
+ Content: mcp.NewTextContent("How do I work with fields and items?"),
+ },
{
Role: "assistant",
- Content: mcp.NewTextContent(fmt.Sprintf("Perfect! I'll help you manage GitHub Projects for %s. Let me guide you through the essential workflows.\n\n"+
- "**🔍 Step 1: Project Discovery**\n"+
- "First, let's see what projects are available using **list_projects**.", owner)),
+ Content: mcp.NewTextContent(`Fields & items:
+1. list_project_fields first → map lowercased name -> {id,type}.
+2. Use only existing field names; no invention.
+3. Iteration mapping: pick sprint/cycle/iteration only if present (sprint:@current etc.).
+4. Include only relevant fields (e.g. Priority + Label for high priority bugs).
+5. Build query after resolving fields ("last week" → updated:>@today-7d).
+6. Paginate until hasNextPage=false; keep query/fields/per_page stable.
+7. Include fields IDs every page when you need their values.
+Missing field? Omit or clarify—never guess.`),
},
{
Role: "user",
- Content: mcp.NewTextContent("Great! After seeing the projects, I want to understand how to work with project fields and items."),
+ Content: mcp.NewTextContent("How do I update item field values?"),
},
{
Role: "assistant",
- Content: mcp.NewTextContent("**📋 Step 2: Understanding Project Structure**\n\n" +
- "Once you select a project, I'll help you:\n\n" +
- "1. **Get field information** using **list_project_fields**\n" +
- " - Find field IDs, names, and data types\n" +
- " - Understand available options for select fields\n" +
- " - Identify required vs. optional fields\n\n" +
- "2. **Query project items** using **list_project_items**\n" +
- " - Filter by assignees: query=\"assignee:@me\"\n" +
- " - Filter by status: query=\"status:In Progress\"\n" +
- " - Filter by labels: query=\"label:bug\"\n" +
- " - Include specific fields: fields=[\"198354254\", \"198354255\"]\n\n" +
- "**💡 Pro Tip:** Always specify the 'fields' parameter to get field values, not just titles!"),
+ Content: mcp.NewTextContent(`Updating fields (project_write update_project_item):
+Examples: text {"id":123,"value":"hello"} | select {"id":456,"value":789} (option ID) | number {"id":321,"value":5} | date {"id":654,"value":"2025-03-15"} | clear {"id":123,"value":null}
+Rules: item_id = project item wrapper ID; confirm field IDs via list_project_fields; select/iteration = pass option/iteration ID (not name).`),
},
{
Role: "user",
- Content: mcp.NewTextContent("How do I update field values? What about the different field types?"),
+ Content: mcp.NewTextContent("Show me a workflow example."),
},
{
Role: "assistant",
- Content: mcp.NewTextContent("**✏️ Step 3: Updating Field Values**\n\n" +
- "Use **update_project_item** with the updated_field parameter. The format varies by field type:\n\n" +
- "**Text fields:**\n" +
- "```json\n" +
- "{\"id\": 123456, \"value\": \"Updated text content\"}\n" +
- "```\n\n" +
- "**Single-select fields:**\n" +
- "```json\n" +
- "{\"id\": 198354254, \"value\": 18498754}\n" +
- "```\n" +
- "*(Use option ID, not option name)*\n\n" +
- "**Date fields:**\n" +
- "```json\n" +
- "{\"id\": 789012, \"value\": \"2024-03-15\"}\n" +
- "```\n\n" +
- "**Number fields:**\n" +
- "```json\n" +
- "{\"id\": 345678, \"value\": 5}\n" +
- "```\n\n" +
- "**Clear a field:**\n" +
- "```json\n" +
- "{\"id\": 123456, \"value\": null}\n" +
- "```\n\n" +
- "**⚠️ Important:** Use the internal project item_id (not issue/PR number) for updates!"),
+ Content: mcp.NewTextContent(`Workflow quick path:
+1 list_projects → pick project_number.
+2 list_project_fields → build field map.
+3 Build query (e.g. is:issue sprint:@current priority:high updated:>@today-7d).
+4 list_project_items (include field IDs) → paginate fully.
+5 Optional deep dive: issue_read / pull_request_read per item.
+6 Optional update: project_write update_project_item.
+Reminders: iteration filter must match existing field; keep fields consistent; summarize only if asked.`),
},
{
Role: "user",
- Content: mcp.NewTextContent("Can you show me a complete workflow example?"),
+ Content: mcp.NewTextContent("How do I handle pagination?"),
},
{
Role: "assistant",
- Content: mcp.NewTextContent(fmt.Sprintf("**🔄 Complete Workflow Example**\n\n"+
- "Here's how to find and update your assigned items:\n\n"+
- "**Step 1:** Discover projects\n\n"+
- "**list_projects** owner=\"%s\" owner_type=\"%s\"\n\n\n"+
- "**Step 2:** Get project fields (using project #123)\n\n"+
- "**list_project_fields** owner=\"%s\" owner_type=\"%s\" project_number=123\n\n"+
- "*(Note the Status field ID, e.g., 198354254)*\n\n"+
- "**Step 3:** Query your assigned items\n\n"+
- "**list_project_items**\n"+
- " owner=\"%s\"\n"+
- " owner_type=\"%s\"\n"+
- " project_number=123\n"+
- " query=\"assignee:@me\"\n"+
- " fields=[\"198354254\", \"other_field_ids\"]\n\n\n"+
- "**Step 4:** Update item status\n\n"+
- "**update_project_item**\n"+
- " owner=\"%s\"\n"+
- " owner_type=\"%s\"\n"+
- " project_number=123\n"+
- " item_id=789123\n"+
- " updated_field={\"id\": 198354254, \"value\": 18498754}\n\n\n"+
- "Let me start by listing your projects now!", owner, ownerType, owner, ownerType, owner, ownerType, owner, ownerType)),
+ Content: mcp.NewTextContent(`Pagination:
+Loop while hasNextPage=true using after=nextCursor.
+Do NOT change query/fields/per_page.
+Include same fields IDs every page.
+Only count/summarize after final page.`),
},
{
Role: "user",
- Content: mcp.NewTextContent("What if I need more details about the items, like recent comments or linked pull requests?"),
+ Content: mcp.NewTextContent("How do I get more details about items?"),
+ },
+ {
+ Role: "assistant",
+ Content: mcp.NewTextContent(`Deep details:
+Use issue_read or pull_request_read for comments/reviews/diffs after enumeration.
+Inputs: repository + item content.number.
+Confirm type (is:issue vs is:pr) before choosing which tool.`),
},
{
Role: "assistant",
- Content: mcp.NewTextContent("**📝 Accessing Underlying Issue/PR Details**\n\n" +
- "Project items contain basic content info, but for detailed information you need to use issue/PR tools:\n\n" +
- "**From project items, extract:**\n" +
- "- content.repository.name and content.repository.owner.login\n" +
- "- content.number (the issue/PR number)\n" +
- "- content_type (\"Issue\" or \"PullRequest\")\n\n" +
- "**Then use these tools for details:**\n\n" +
- "1. **Get full issue/PR details:**\n" +
- " - **get_issue** owner=repo_owner repo=repo_name issue_number=123\n" +
- " - Returns: full body, labels, assignees, milestone, etc.\n\n" +
- "2. **Get recent comments:**\n" +
- " - **list_comments** owner=repo_owner repo=repo_name issue_number=123\n" +
- " - Add since parameter to filter recent comments\n\n" +
- "3. **Get issue events:**\n" +
- " - **list_issue_events** owner=repo_owner repo=repo_name issue_number=123\n" +
- " - Shows timeline: assignments, label changes, status updates\n\n" +
- "4. **For pull requests specifically:**\n" +
- " - **get_pull_request** owner=repo_owner repo=repo_name pull_number=123\n" +
- " - **list_pull_request_reviews** for review status\n\n" +
- "**💡 Example:** To check for blockers in comments:\n" +
- "1. Get project items with query=\"assignee:@me is:open\"\n" +
- "2. For each item, extract repository and issue number from content\n" +
- "3. Use **list_comments** to get recent comments\n" +
- "4. Search comments for keywords like \"blocked\", \"blocker\", \"waiting\""),
+ Content: mcp.NewTextContent(`Query patterns:
+blocked issues → is:issue (label:blocked OR status:"Blocked")
+overdue tasks → is:issue due-date:<@today state:open
+PRs ready for review → is:pr review-status:"Ready for Review" state:open
+stale issues → is:issue updated:<@today-30d state:open
+high priority bugs → is:issue label:bug priority:high state:open
+team sprint PRs → is:pr team-name:"Backend Team" sprint:@current
+Rules: summarize only if asked; dedupe before counts; quote multi-word values; never invent field names or IDs.`),
},
}
return &mcp.GetPromptResult{
diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go
index 6cfbda0fe..5b7f96dc4 100644
--- a/pkg/github/projects_test.go
+++ b/pkg/github/projects_test.go
@@ -3,7 +3,6 @@ package github
import (
"context"
"encoding/json"
- "io"
"net/http"
"testing"
@@ -15,32 +14,40 @@ import (
"github.com/stretchr/testify/require"
)
-func Test_ListProjects(t *testing.T) {
+func Test_ProjectRead(t *testing.T) {
mockClient := gh.NewClient(nil)
- tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ tool, _ := ProjectRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
- assert.Equal(t, "list_projects", tool.Name)
+ assert.Equal(t, "project_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, "owner_type")
+ assert.Contains(t, tool.InputSchema.Properties, "project_number")
+ assert.Contains(t, tool.InputSchema.Properties, "field_id")
+ assert.Contains(t, tool.InputSchema.Properties, "item_id")
assert.Contains(t, tool.InputSchema.Properties, "query")
assert.Contains(t, tool.InputSchema.Properties, "per_page")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"})
+ assert.Contains(t, tool.InputSchema.Properties, "fields")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "owner_type"})
orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}}
- userProjects := []map[string]any{{"id": 2, "title": "User Project"}}
+ orgProject := map[string]any{"id": 1, "title": "Org Project"}
+ projectFields := []map[string]any{{"id": 100, "name": "Status"}}
+ projectField := map[string]any{"id": 100, "name": "Status"}
+ projectItems := []map[string]any{{"id": 1000, "title": "Item 1"}}
+ projectItem := map[string]any{"id": 1000, "title": "Item 1"}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
- expectedLength int
expectedErrMsg string
}{
{
- name: "success organization",
+ name: "list_projects success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet},
@@ -48,1602 +55,433 @@ func Test_ListProjects(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
+ "method": "list_projects",
"owner": "octo-org",
"owner_type": "org",
},
- expectError: false,
- expectedLength: 1,
- },
- {
- name: "success user",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, userProjects),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "octocat",
- "owner_type": "user",
- },
- expectError: false,
- expectedLength: 1,
+ expectError: false,
},
{
- name: "success organization with pagination & query",
+ name: "get_project success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- q := r.URL.Query()
- if q.Get("per_page") == "50" && q.Get("q") == "roadmap" {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(orgProjects))
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
- }),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, orgProject),
),
),
requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- "per_page": float64(50),
- "query": "roadmap",
+ "method": "get_project",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
},
- expectError: false,
- expectedLength: 1,
+ expectError: false,
},
{
- name: "api error",
+ name: "list_project_fields success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/fields", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, projectFields),
),
),
requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- },
- expectError: true,
- expectedErrMsg: "failed to list projects",
- },
- {
- name: "missing owner",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner_type": "org",
- },
- expectError: true,
- },
- {
- name: "missing owner_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "octo-org",
+ "method": "list_project_fields",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
},
- expectError: true,
+ expectError: false,
},
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- require.NoError(t, err)
- if tc.expectError {
- require.True(t, result.IsError)
- text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- if tc.name == "missing owner" {
- assert.Contains(t, text, "missing required parameter: owner")
- }
- if tc.name == "missing owner_type" {
- assert.Contains(t, text, "missing required parameter: owner_type")
- }
- return
- }
-
- require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var arr []map[string]any
- err = json.Unmarshal([]byte(textContent.Text), &arr)
- require.NoError(t, err)
- assert.Equal(t, tc.expectedLength, len(arr))
- })
- }
-}
-
-func Test_GetProject(t *testing.T) {
- mockClient := gh.NewClient(nil)
- tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "get_project", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"})
-
- project := map[string]any{"id": 123, "title": "Project Title"}
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedErrMsg string
- }{
{
- name: "success organization project fetch",
+ name: "get_project_field success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, project),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/fields/{field_id}", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, projectField),
),
),
requestArgs: map[string]interface{}{
- "project_number": float64(123),
+ "method": "get_project_field",
"owner": "octo-org",
"owner_type": "org",
+ "project_number": float64(1),
+ "field_id": float64(100),
},
expectError: false,
},
{
- name: "success user project fetch",
+ name: "list_project_items success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, project),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, projectItems),
),
),
requestArgs: map[string]interface{}{
- "project_number": float64(456),
- "owner": "octocat",
- "owner_type": "user",
+ "method": "list_project_items",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
},
expectError: false,
},
{
- name: "api error",
+ name: "get_project_item success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, projectItem),
),
),
requestArgs: map[string]interface{}{
- "project_number": float64(999),
+ "method": "get_project_item",
"owner": "octo-org",
"owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(1000),
},
- expectError: true,
- expectedErrMsg: "failed to get project",
+ expectError: false,
},
{
- name: "missing project_number",
+ name: "missing method",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
"owner": "octo-org",
"owner_type": "org",
},
- expectError: true,
+ expectError: true,
+ expectedErrMsg: "missing required parameter: method",
},
{
name: "missing owner",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
- "project_number": float64(123),
- "owner_type": "org",
+ "method": "list_projects",
+ "owner_type": "org",
},
- expectError: true,
+ expectError: true,
+ expectedErrMsg: "missing required parameter: owner",
},
{
- name: "missing owner_type",
+ name: "invalid method",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
- "project_number": float64(123),
- "owner": "octo-org",
+ "method": "invalid_method",
+ "owner": "octo-org",
+ "owner_type": "org",
},
- expectError: true,
+ expectError: true,
+ expectedErrMsg: "unknown method: invalid_method",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
+ ctx := context.Background()
+ mockClient := gh.NewClient(tc.mockedClient)
+ _, handler := ProjectRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ result, err := handler(ctx, createMCPRequest(tc.requestArgs))
require.NoError(t, err)
+
if tc.expectError {
require.True(t, result.IsError)
text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- if tc.name == "missing project_number" {
- assert.Contains(t, text, "missing required parameter: project_number")
- }
- if tc.name == "missing owner" {
- assert.Contains(t, text, "missing required parameter: owner")
- }
- if tc.name == "missing owner_type" {
- assert.Contains(t, text, "missing required parameter: owner_type")
- }
+ assert.Contains(t, text, tc.expectedErrMsg)
return
}
require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var arr map[string]any
- err = json.Unmarshal([]byte(textContent.Text), &arr)
- require.NoError(t, err)
})
}
}
-func Test_ListProjectFields(t *testing.T) {
+// Test_ProjectRead_FilterSpecialFieldTypes_ListProjectFields ensures that special intrinsic
+// field data types (e.g., labels, repository, title) are filtered out of the response
+// by list_project_fields. Only non-special field data types should remain.
+func Test_ProjectRead_FilterSpecialFieldTypes_ListProjectFields(t *testing.T) {
+ // Mock fields returned by the API including special types.
+ projectFields := []map[string]any{
+ {"id": 1, "name": "Title", "data_type": "title"},
+ {"id": 2, "name": "Labels", "data_type": "labels"},
+ {"id": 3, "name": "Status", "data_type": "single_select"},
+ {"id": 4, "name": "Repository", "data_type": "repository"},
+ }
+
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/fields", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, projectFields),
+ ),
+ )
+
+ ctx := context.Background()
+ client := gh.NewClient(mockedClient)
+ _, handler := ProjectRead(stubGetClientFn(client), translations.NullTranslationHelper)
+ result, err := handler(ctx, createMCPRequest(map[string]any{
+ "method": "list_project_fields",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ }))
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ text := getTextResult(t, result).Text
+ var parsed map[string]any
+ require.NoError(t, json.Unmarshal([]byte(text), &parsed))
+
+ fieldsAny, ok := parsed["fields"].([]any)
+ require.True(t, ok, "expected fields array in response")
+
+ // Only the non-special field (single_select) should remain.
+ assert.Equal(t, 1, len(fieldsAny))
+ fieldObj := fieldsAny[0].(map[string]any)
+ assert.Equal(t, float64(3), fieldObj["id"], "expected Status field to remain")
+ assert.Equal(t, "single_select", fieldObj["data_type"])
+}
+
+// helper returning a project item seeded with special intrinsic field types.
+func testProjectItemWithSpecialFields() map[string]any {
+ return map[string]any{
+ "id": 1000,
+ "title": "Item 1",
+ "fields": []map[string]any{
+ {"id": 1, "name": "Title", "data_type": "title", "value": map[string]any{"raw": "Item 1"}},
+ {"id": 2, "name": "Labels", "data_type": "labels", "value": []string{"bug"}},
+ {"id": 3, "name": "Status", "data_type": "single_select", "value": map[string]any{"name": map[string]any{"raw": "In Progress"}}},
+ },
+ }
+}
+
+// Test_ProjectRead_FilterSpecialFieldTypes_GetProjectItem validates filtering for get_project_item
+// so that special field data types are removed from each item's field values slice.
+func Test_ProjectRead_FilterSpecialFieldTypes_GetProjectItem(t *testing.T) {
+ projectItem := testProjectItemWithSpecialFields()
+
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, projectItem),
+ ),
+ )
+
+ ctx := context.Background()
+ client := gh.NewClient(mockedClient)
+ _, handler := ProjectRead(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ resGet, err := handler(ctx, createMCPRequest(map[string]any{
+ "method": "get_project_item",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(1000),
+ }))
+ require.NoError(t, err)
+ require.False(t, resGet.IsError)
+
+ textGet := getTextResult(t, resGet).Text
+ var parsedGet map[string]any
+ require.NoError(t, json.Unmarshal([]byte(textGet), &parsedGet))
+ fieldsAny, ok := parsedGet["fields"].([]any)
+ require.True(t, ok)
+ assert.Equal(t, 1, len(fieldsAny), "expected only non-special field to remain for get_project_item")
+ fieldObj := fieldsAny[0].(map[string]any)
+ assert.Equal(t, float64(3), fieldObj["id"])
+ assert.Equal(t, "single_select", fieldObj["data_type"])
+}
+
+// Test_ProjectRead_FilterSpecialFieldTypes_ListProjectItems validates filtering for list_project_items
+// so that special field data types are removed from each item's field values slice.
+func Test_ProjectRead_FilterSpecialFieldTypes_ListProjectItems(t *testing.T) {
+ projectItem := testProjectItemWithSpecialFields()
+ projectItems := []map[string]any{projectItem}
+
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items", Method: http.MethodGet},
+ mockResponse(t, http.StatusOK, projectItems),
+ ),
+ )
+
+ ctx := context.Background()
+ client := gh.NewClient(mockedClient)
+ _, handler := ProjectRead(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ resList, err := handler(ctx, createMCPRequest(map[string]any{
+ "method": "list_project_items",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ }))
+ require.NoError(t, err)
+ require.False(t, resList.IsError)
+
+ textList := getTextResult(t, resList).Text
+ var parsedList map[string]any
+ require.NoError(t, json.Unmarshal([]byte(textList), &parsedList))
+ itemsAny, ok := parsedList["items"].([]any)
+ require.True(t, ok)
+ require.Equal(t, 1, len(itemsAny))
+ itemObj := itemsAny[0].(map[string]any)
+ itemFields, ok := itemObj["fields"].([]any)
+ require.True(t, ok)
+ assert.Equal(t, 1, len(itemFields), "expected only non-special field to remain for list_project_items")
+ itemFieldObj := itemFields[0].(map[string]any)
+ assert.Equal(t, float64(3), itemFieldObj["id"])
+ assert.Equal(t, "single_select", itemFieldObj["data_type"])
+}
+
+func Test_ProjectWrite(t *testing.T) {
mockClient := gh.NewClient(nil)
- tool, _ := ListProjectFields(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ tool, _ := ProjectWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
- assert.Equal(t, "list_project_fields", tool.Name)
+ assert.Equal(t, "project_write", tool.Name)
assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
+ assert.Contains(t, tool.InputSchema.Properties, "method")
assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "owner_type")
assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "per_page")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"})
+ assert.Contains(t, tool.InputSchema.Properties, "item_id")
+ assert.Contains(t, tool.InputSchema.Properties, "item_type")
+ assert.Contains(t, tool.InputSchema.Properties, "updated_field")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "owner_type", "project_number", "item_id"})
- orgFields := []map[string]any{
- {"id": 101, "name": "Status", "dataType": "single_select"},
- }
- userFields := []map[string]any{
- {"id": 201, "name": "Priority", "dataType": "single_select"},
- }
+ addedItem := map[string]any{"id": 1000, "title": "Added Item"}
+ updatedItem := map[string]any{"id": 1000, "title": "Updated Item"}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
- expectedLength int
expectedErrMsg string
}{
{
- name: "success organization fields",
+ name: "add_project_item success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, orgFields),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items", Method: http.MethodPost},
+ mockResponse(t, http.StatusCreated, addedItem),
),
),
requestArgs: map[string]interface{}{
+ "method": "add_project_item",
"owner": "octo-org",
"owner_type": "org",
- "project_number": float64(123),
+ "project_number": float64(1),
+ "item_id": float64(123),
+ "item_type": "issue",
},
- expectedLength: 1,
+ expectError: false,
},
{
- name: "success user fields with per_page override",
+ name: "update_project_item success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- q := r.URL.Query()
- if q.Get("per_page") == "50" {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(userFields))
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
- }),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodPatch},
+ mockResponse(t, http.StatusOK, updatedItem),
),
),
requestArgs: map[string]interface{}{
- "owner": "octocat",
- "owner_type": "user",
- "project_number": float64(456),
- "per_page": float64(50),
+ "method": "update_project_item",
+ "owner": "octo-org",
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(1000),
+ "updated_field": map[string]any{"id": float64(100), "value": "New Value"},
},
- expectedLength: 1,
+ expectError: false,
},
{
- name: "api error",
+ name: "delete_project_item success",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
+ mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project_number}/items/{item_id}", Method: http.MethodDelete},
+ mockResponse(t, http.StatusNoContent, nil),
),
),
requestArgs: map[string]interface{}{
+ "method": "delete_project_item",
"owner": "octo-org",
"owner_type": "org",
- "project_number": float64(789),
+ "project_number": float64(1),
+ "item_id": float64(1000),
},
- expectError: true,
- expectedErrMsg: "failed to list project fields",
+ expectError: false,
},
{
- name: "missing owner",
+ name: "missing method",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
+ "owner": "octo-org",
"owner_type": "org",
- "project_number": 10,
+ "project_number": float64(1),
+ "item_id": float64(123),
},
- expectError: true,
+ expectError: true,
+ expectedErrMsg: "missing required parameter: method",
},
{
- name: "missing owner_type",
+ name: "add_project_item missing item_type",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
+ "method": "add_project_item",
"owner": "octo-org",
- "project_number": 10,
+ "owner_type": "org",
+ "project_number": float64(1),
+ "item_id": float64(123),
},
- expectError: true,
+ expectError: true,
+ expectedErrMsg: "missing required parameter: item_type",
},
{
- name: "missing project_number",
+ name: "update_project_item missing updated_field",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- },
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := ListProjectFields(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- require.NoError(t, err)
- if tc.expectError {
- require.True(t, result.IsError)
- text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- if tc.name == "missing owner" {
- assert.Contains(t, text, "missing required parameter: owner")
- }
- if tc.name == "missing owner_type" {
- assert.Contains(t, text, "missing required parameter: owner_type")
- }
- if tc.name == "missing project_number" {
- assert.Contains(t, text, "missing required parameter: project_number")
- }
- return
- }
-
- require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var fields []map[string]any
- err = json.Unmarshal([]byte(textContent.Text), &fields)
- require.NoError(t, err)
- assert.Equal(t, tc.expectedLength, len(fields))
- })
- }
-}
-
-func Test_GetProjectField(t *testing.T) {
- mockClient := gh.NewClient(nil)
- tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "get_project_field", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "field_id")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"})
-
- orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"}
- userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"}
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]any
- expectError bool
- expectedErrMsg string
- expectedID int
- }{
- {
- name: "success organization field",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, orgField),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(123),
- "field_id": float64(101),
- },
- expectedID: 101,
- },
- {
- name: "success user field",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, userField),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octocat",
- "owner_type": "user",
- "project_number": float64(456),
- "field_id": float64(202),
- },
- expectedID: 202,
- },
- {
- name: "api error",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
- ),
- ),
- requestArgs: map[string]any{
+ "method": "update_project_item",
"owner": "octo-org",
"owner_type": "org",
- "project_number": float64(789),
- "field_id": float64(303),
+ "project_number": float64(1),
+ "item_id": float64(1000),
},
expectError: true,
- expectedErrMsg: "failed to get project field",
- },
- {
- name: "missing owner",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner_type": "org",
- "project_number": float64(10),
- "field_id": float64(1),
- },
- expectError: true,
- },
- {
- name: "missing owner_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "project_number": float64(10),
- "field_id": float64(1),
- },
- expectError: true,
- },
- {
- name: "missing project_number",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "field_id": float64(1),
- },
- expectError: true,
+ expectedErrMsg: "missing required parameter: updated_field",
},
{
- name: "missing field_id",
+ name: "invalid method",
mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
+ requestArgs: map[string]interface{}{
+ "method": "invalid_method",
"owner": "octo-org",
"owner_type": "org",
- "project_number": float64(10),
+ "project_number": float64(1),
+ "item_id": float64(123),
},
- expectError: true,
+ expectError: true,
+ expectedErrMsg: "unknown method: invalid_method",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
+ ctx := context.Background()
+ mockClient := gh.NewClient(tc.mockedClient)
+ _, handler := ProjectWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ result, err := handler(ctx, createMCPRequest(tc.requestArgs))
require.NoError(t, err)
- if tc.expectError {
- require.True(t, result.IsError)
- text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- if tc.name == "missing owner" {
- assert.Contains(t, text, "missing required parameter: owner")
- }
- if tc.name == "missing owner_type" {
- assert.Contains(t, text, "missing required parameter: owner_type")
- }
- if tc.name == "missing project_number" {
- assert.Contains(t, text, "missing required parameter: project_number")
- }
- if tc.name == "missing field_id" {
- assert.Contains(t, text, "missing required parameter: field_id")
- }
- return
- }
-
- require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var field map[string]any
- err = json.Unmarshal([]byte(textContent.Text), &field)
- require.NoError(t, err)
- if tc.expectedID != 0 {
- assert.Equal(t, float64(tc.expectedID), field["id"])
- }
- })
- }
-}
-
-func Test_ListProjectItems(t *testing.T) {
- mockClient := gh.NewClient(nil)
- tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "list_project_items", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "query")
- assert.Contains(t, tool.InputSchema.Properties, "per_page")
- assert.Contains(t, tool.InputSchema.Properties, "fields")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"})
-
- orgItems := []map[string]any{
- {"id": 301, "content_type": "Issue", "project_node_id": "PR_1", "fields": []map[string]any{
- {"id": 123, "name": "Status", "data_type": "single_select", "value": "value1"},
- {"id": 456, "name": "Priority", "data_type": "single_select", "value": "value2"},
- }},
- }
- userItems := []map[string]any{
- {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"},
- {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"},
- }
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedLength int
- expectedErrMsg string
- }{
- {
- name: "success organization items",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, orgItems),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(123),
- },
- expectedLength: 1,
- },
- {
- name: "success organization items with fields",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- q := r.URL.Query()
- fieldParams := q["fields"]
- if len(fieldParams) == 3 && fieldParams[0] == "123" && fieldParams[1] == "456" && fieldParams[2] == "789" {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(orgItems))
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(123),
- "fields": []interface{}{"123", "456", "789"},
- },
- expectedLength: 1,
- },
- {
- name: "success user items",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, userItems),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "octocat",
- "owner_type": "user",
- "project_number": float64(456),
- },
- expectedLength: 2,
- },
- {
- name: "success with pagination and query",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- q := r.URL.Query()
- if q.Get("per_page") == "50" && q.Get("q") == "bug" {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(orgItems))
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(123),
- "per_page": float64(50),
- "query": "bug",
- },
- expectedLength: 1,
- },
- {
- name: "api error",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(789),
- },
- expectError: true,
- expectedErrMsg: ProjectListFailedError,
- },
- {
- name: "missing owner",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner_type": "org",
- "project_number": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing owner_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "project_number": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing project_number",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "octo-org",
- "owner_type": "org",
- },
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := ListProjectItems(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- require.NoError(t, err)
- if tc.expectError {
- require.True(t, result.IsError)
- text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- if tc.name == "missing owner" {
- assert.Contains(t, text, "missing required parameter: owner")
- }
- if tc.name == "missing owner_type" {
- assert.Contains(t, text, "missing required parameter: owner_type")
- }
- if tc.name == "missing project_number" {
- assert.Contains(t, text, "missing required parameter: project_number")
- }
- return
- }
-
- require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var items []map[string]any
- err = json.Unmarshal([]byte(textContent.Text), &items)
- require.NoError(t, err)
- assert.Equal(t, tc.expectedLength, len(items))
- })
- }
-}
-
-func Test_GetProjectItem(t *testing.T) {
- mockClient := gh.NewClient(nil)
- tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "get_project_item", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "item_id")
- assert.Contains(t, tool.InputSchema.Properties, "fields")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"})
-
- orgItem := map[string]any{
- "id": 301,
- "content_type": "Issue",
- "project_node_id": "PR_1",
- "creator": map[string]any{"login": "octocat"},
- }
- userItem := map[string]any{
- "id": 501,
- "content_type": "PullRequest",
- "project_node_id": "PR_2",
- "creator": map[string]any{"login": "jane"},
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]any
- expectError bool
- expectedErrMsg string
- expectedID int
- }{
- {
- name: "success organization item",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, orgItem),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(123),
- "item_id": float64(301),
- },
- expectedID: 301,
- },
- {
- name: "success organization item with fields",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- q := r.URL.Query()
- fieldParams := q["fields"]
- if len(fieldParams) == 2 && fieldParams[0] == "123" && fieldParams[1] == "456" {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(orgItem))
- return
- }
- w.WriteHeader(http.StatusBadRequest)
- _, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
- }),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(123),
- "item_id": float64(301),
- "fields": []interface{}{"123", "456"},
- },
- expectedID: 301,
- },
- {
- name: "success user item",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet},
- mockResponse(t, http.StatusOK, userItem),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octocat",
- "owner_type": "user",
- "project_number": float64(456),
- "item_id": float64(501),
- },
- expectedID: 501,
- },
- {
- name: "api error",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(789),
- "item_id": float64(999),
- },
- expectError: true,
- expectedErrMsg: "failed to get project item",
- },
- {
- name: "missing owner",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner_type": "org",
- "project_number": float64(10),
- "item_id": float64(1),
- },
- expectError: true,
- },
- {
- name: "missing owner_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "project_number": float64(10),
- "item_id": float64(1),
- },
- expectError: true,
- },
- {
- name: "missing project_number",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "item_id": float64(1),
- },
- expectError: true,
- },
- {
- name: "missing item_id",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(10),
- },
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := GetProjectItem(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- require.NoError(t, err)
- if tc.expectError {
- require.True(t, result.IsError)
- text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- if tc.name == "missing owner" {
- assert.Contains(t, text, "missing required parameter: owner")
- }
- if tc.name == "missing owner_type" {
- assert.Contains(t, text, "missing required parameter: owner_type")
- }
- if tc.name == "missing project_number" {
- assert.Contains(t, text, "missing required parameter: project_number")
- }
- if tc.name == "missing item_id" {
- assert.Contains(t, text, "missing required parameter: item_id")
- }
- return
- }
-
- require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var item map[string]any
- err = json.Unmarshal([]byte(textContent.Text), &item)
- require.NoError(t, err)
- if tc.expectedID != 0 {
- assert.Equal(t, float64(tc.expectedID), item["id"])
- }
- })
- }
-}
-
-func Test_AddProjectItem(t *testing.T) {
- mockClient := gh.NewClient(nil)
- tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "add_project_item", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "item_type")
- assert.Contains(t, tool.InputSchema.Properties, "item_id")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"})
-
- orgItem := map[string]any{
- "id": 601,
- "content_type": "Issue",
- "creator": map[string]any{
- "login": "octocat",
- "id": 1,
- "html_url": "https://github.com/octocat",
- "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4",
- },
- }
-
- userItem := map[string]any{
- "id": 701,
- "content_type": "PullRequest",
- "creator": map[string]any{
- "login": "hubot",
- "id": 2,
- "html_url": "https://github.com/hubot",
- "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4",
- },
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]any
- expectError bool
- expectedErrMsg string
- expectedID int
- expectedContentType string
- expectedCreatorLogin string
- }{
- {
- name: "success organization issue",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, err := io.ReadAll(r.Body)
- assert.NoError(t, err)
- var payload struct {
- Type string `json:"type"`
- ID int `json:"id"`
- }
- assert.NoError(t, json.Unmarshal(body, &payload))
- assert.Equal(t, "Issue", payload.Type)
- assert.Equal(t, 9876, payload.ID)
- w.WriteHeader(http.StatusCreated)
- _, _ = w.Write(mock.MustMarshal(orgItem))
- }),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(321),
- "item_type": "issue",
- "item_id": float64(9876),
- },
- expectedID: 601,
- expectedContentType: "Issue",
- expectedCreatorLogin: "octocat",
- },
- {
- name: "success user pull request",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, err := io.ReadAll(r.Body)
- assert.NoError(t, err)
- var payload struct {
- Type string `json:"type"`
- ID int `json:"id"`
- }
- assert.NoError(t, json.Unmarshal(body, &payload))
- assert.Equal(t, "PullRequest", payload.Type)
- assert.Equal(t, 7654, payload.ID)
- w.WriteHeader(http.StatusCreated)
- _, _ = w.Write(mock.MustMarshal(userItem))
- }),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octocat",
- "owner_type": "user",
- "project_number": float64(222),
- "item_type": "pull_request",
- "item_id": float64(7654),
- },
- expectedID: 701,
- expectedContentType: "PullRequest",
- expectedCreatorLogin: "hubot",
- },
- {
- name: "api error",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(999),
- "item_type": "issue",
- "item_id": float64(8888),
- },
- expectError: true,
- expectedErrMsg: ProjectAddFailedError,
- },
- {
- name: "missing owner",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner_type": "org",
- "project_number": float64(1),
- "item_type": "Issue",
- "item_id": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing owner_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "project_number": float64(1),
- "item_type": "Issue",
- "item_id": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing project_number",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "item_type": "Issue",
- "item_id": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing item_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- "item_id": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing item_id",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- "item_type": "Issue",
- },
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := AddProjectItem(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
-
- result, err := handler(context.Background(), request)
- require.NoError(t, err)
-
- if tc.expectError {
- require.True(t, result.IsError)
- text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- switch tc.name {
- case "missing owner":
- assert.Contains(t, text, "missing required parameter: owner")
- case "missing owner_type":
- assert.Contains(t, text, "missing required parameter: owner_type")
- case "missing project_number":
- assert.Contains(t, text, "missing required parameter: project_number")
- case "missing item_type":
- assert.Contains(t, text, "missing required parameter: item_type")
- case "missing item_id":
- assert.Contains(t, text, "missing required parameter: item_id")
- // case "api error":
- // assert.Contains(t, text, ProjectAddFailedError)
- }
- return
- }
-
- require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var item map[string]any
- require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item))
- if tc.expectedID != 0 {
- assert.Equal(t, float64(tc.expectedID), item["id"])
- }
- if tc.expectedContentType != "" {
- assert.Equal(t, tc.expectedContentType, item["content_type"])
- }
- if tc.expectedCreatorLogin != "" {
- creator, ok := item["creator"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, tc.expectedCreatorLogin, creator["login"])
- }
- })
- }
-}
-
-func Test_UpdateProjectItem(t *testing.T) {
- mockClient := gh.NewClient(nil)
- tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "update_project_item", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "item_id")
- assert.Contains(t, tool.InputSchema.Properties, "updated_field")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"})
-
- orgUpdatedItem := map[string]any{
- "id": 801,
- "content_type": "Issue",
- }
- userUpdatedItem := map[string]any{
- "id": 802,
- "content_type": "PullRequest",
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]any
- expectError bool
- expectedErrMsg string
- expectedID int
- }{
- {
- name: "success organization update",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, err := io.ReadAll(r.Body)
- assert.NoError(t, err)
- var payload struct {
- Fields []struct {
- ID int `json:"id"`
- Value interface{} `json:"value"`
- } `json:"fields"`
- }
- assert.NoError(t, json.Unmarshal(body, &payload))
- require.Len(t, payload.Fields, 1)
- assert.Equal(t, 101, payload.Fields[0].ID)
- assert.Equal(t, "Done", payload.Fields[0].Value)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(orgUpdatedItem))
- }),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1001),
- "item_id": float64(5555),
- "updated_field": map[string]any{
- "id": float64(101),
- "value": "Done",
- },
- },
- expectedID: 801,
- },
- {
- name: "success user update",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch},
- http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, err := io.ReadAll(r.Body)
- assert.NoError(t, err)
- var payload struct {
- Fields []struct {
- ID int `json:"id"`
- Value interface{} `json:"value"`
- } `json:"fields"`
- }
- assert.NoError(t, json.Unmarshal(body, &payload))
- require.Len(t, payload.Fields, 1)
- assert.Equal(t, 202, payload.Fields[0].ID)
- assert.Equal(t, 42.0, payload.Fields[0].Value)
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(userUpdatedItem))
- }),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octocat",
- "owner_type": "user",
- "project_number": float64(2002),
- "item_id": float64(6666),
- "updated_field": map[string]any{
- "id": float64(202),
- "value": float64(42),
- },
- },
- expectedID: 802,
- },
- {
- name: "api error",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(3003),
- "item_id": float64(7777),
- "updated_field": map[string]any{
- "id": float64(303),
- "value": "In Progress",
- },
- },
- expectError: true,
- expectedErrMsg: "failed to update a project item",
- },
- {
- name: "missing owner",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner_type": "org",
- "project_number": float64(1),
- "item_id": float64(2),
- "field_id": float64(1),
- "new_field": map[string]any{
- "value": "X",
- },
- },
- expectError: true,
- },
- {
- name: "missing owner_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "project_number": float64(1),
- "item_id": float64(2),
- "new_field": map[string]any{
- "id": float64(1),
- "value": "X",
- },
- },
- expectError: true,
- },
- {
- name: "missing project_number",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "item_id": float64(2),
- "new_field": map[string]any{
- "id": float64(1),
- "value": "X",
- },
- },
- expectError: true,
- },
- {
- name: "missing item_id",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- "new_field": map[string]any{
- "id": float64(1),
- "value": "X",
- },
- },
- expectError: true,
- },
- {
- name: "missing field_value",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- "item_id": float64(2),
- "field_id": float64(2),
- },
- expectError: true,
- },
- {
- name: "new_field not object",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- "item_id": float64(2),
- "updated_field": "not-an-object",
- },
- expectError: true,
- },
- {
- name: "new_field missing id",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- "item_id": float64(2),
- "updated_field": map[string]any{},
- },
- expectError: true,
- },
- {
- name: "new_field missing value",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- "item_id": float64(2),
- "updated_field": map[string]any{
- "id": float64(9),
- },
- },
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := UpdateProjectItem(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- require.NoError(t, err)
- if tc.expectError {
- require.True(t, result.IsError)
- text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- switch tc.name {
- case "missing owner":
- assert.Contains(t, text, "missing required parameter: owner")
- case "missing owner_type":
- assert.Contains(t, text, "missing required parameter: owner_type")
- case "missing project_number":
- assert.Contains(t, text, "missing required parameter: project_number")
- case "missing item_id":
- assert.Contains(t, text, "missing required parameter: item_id")
- case "missing field_value":
- assert.Contains(t, text, "missing required parameter: updated_field")
- case "field_value not object":
- assert.Contains(t, text, "field_value must be an object")
- case "field_value missing id":
- assert.Contains(t, text, "missing required parameter: field_id")
- case "field_value missing value":
- assert.Contains(t, text, "field_value.value is required")
- }
- return
- }
-
- require.False(t, result.IsError)
- textContent := getTextResult(t, result)
- var item map[string]any
- require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item))
- if tc.expectedID != 0 {
- assert.Equal(t, float64(tc.expectedID), item["id"])
- }
- })
- }
-}
-
-func Test_DeleteProjectItem(t *testing.T) {
- mockClient := gh.NewClient(nil)
- tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "delete_project_item", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner_type")
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "project_number")
- assert.Contains(t, tool.InputSchema.Properties, "item_id")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"})
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]any
- expectError bool
- expectedErrMsg string
- expectedText string
- }{
- {
- name: "success organization delete",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete},
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNoContent)
- }),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(123),
- "item_id": float64(555),
- },
- expectedText: "project item successfully deleted",
- },
- {
- name: "success user delete",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete},
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNoContent)
- }),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octocat",
- "owner_type": "user",
- "project_number": float64(456),
- "item_id": float64(777),
- },
- expectedText: "project item successfully deleted",
- },
- {
- name: "api error",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete},
- mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
- ),
- ),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(321),
- "item_id": float64(999),
- },
- expectError: true,
- expectedErrMsg: ProjectDeleteFailedError,
- },
- {
- name: "missing owner",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner_type": "org",
- "project_number": float64(1),
- "item_id": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing owner_type",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "project_number": float64(1),
- "item_id": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing project_number",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "item_id": float64(10),
- },
- expectError: true,
- },
- {
- name: "missing item_id",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]any{
- "owner": "octo-org",
- "owner_type": "org",
- "project_number": float64(1),
- },
- expectError: true,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := gh.NewClient(tc.mockedClient)
- _, handler := DeleteProjectItem(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- require.NoError(t, err)
if tc.expectError {
require.True(t, result.IsError)
text := getTextResult(t, result).Text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, text, tc.expectedErrMsg)
- }
- switch tc.name {
- case "missing owner":
- assert.Contains(t, text, "missing required parameter: owner")
- case "missing owner_type":
- assert.Contains(t, text, "missing required parameter: owner_type")
- case "missing project_number":
- assert.Contains(t, text, "missing required parameter: project_number")
- case "missing item_id":
- assert.Contains(t, text, "missing required parameter: item_id")
- }
+ assert.Contains(t, text, tc.expectedErrMsg)
return
}
require.False(t, result.IsError)
- text := getTextResult(t, result).Text
- assert.Contains(t, text, tc.expectedText)
})
}
}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 4296aaa72..8489109da 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -317,20 +317,13 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
projects := toolsets.NewToolset(ToolsetMetadataProjects.ID, ToolsetMetadataProjects.Description).
AddReadTools(
- toolsets.NewServerTool(ListProjects(getClient, t)),
- toolsets.NewServerTool(GetProject(getClient, t)),
- toolsets.NewServerTool(ListProjectFields(getClient, t)),
- toolsets.NewServerTool(GetProjectField(getClient, t)),
- toolsets.NewServerTool(ListProjectItems(getClient, t)),
- toolsets.NewServerTool(GetProjectItem(getClient, t)),
+ toolsets.NewServerTool(ProjectRead(getClient, t)),
).
AddWriteTools(
- toolsets.NewServerTool(AddProjectItem(getClient, t)),
- toolsets.NewServerTool(DeleteProjectItem(getClient, t)),
- toolsets.NewServerTool(UpdateProjectItem(getClient, t)),
+ toolsets.NewServerTool(ProjectWrite(getClient, t)),
).AddPrompts(
toolsets.NewServerPrompt(ManageProjectItemsPrompt(t)),
- )
+ ).AddResourceTemplates()
stargazers := toolsets.NewToolset(ToolsetMetadataStargazers.ID, ToolsetMetadataStargazers.Description).
AddReadTools(
toolsets.NewServerTool(ListStarredRepositories(getClient, t)),