Skip to main content
Glama

get_project_items

Retrieve and filter items from GitHub Projects V2 by state or custom field values to manage project tasks efficiently.

Instructions

Get items in a GitHub Project V2. Can filter by state OR a single custom field=value.

Args:
    owner: The GitHub organization or user name
    project_number: The project number
    limit: Maximum number of items to return (default: 50). When filtering, the system automatically fetches more items to improve efficiency.
    state: Optional state filter (e.g., "OPEN", "CLOSED"). Applies to Issues/PRs.
    filter_field_name: Optional custom field name to filter by (e.g., "Status"). Currently supports SingleSelect and Iteration fields.
    filter_field_value: Optional custom field value to filter by (e.g., "In Development"). Uses case-insensitive matching.
    cursor: Optional cursor for pagination. Use value from previous results to get next page.

Returns:
    A formatted string with item details.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
ownerYes
project_numberYes
limitNo
stateNo
filter_field_nameNo
filter_field_valueNo
cursorNo

Implementation Reference

  • The primary MCP tool handler and registration for 'get_project_items'. It is decorated with @mcp.tool(), validates inputs, delegates to GitHubClient.get_project_items, formats the response with item details, field values, pagination, and handles no-results debug info.
    @mcp.tool()
    async def get_project_items(
        owner: str,
        project_number: int,
        limit: int = 50,
        state: Optional[str] = None,
        filter_field_name: Optional[str] = None,
        filter_field_value: Optional[str] = None,
        cursor: Optional[str] = None,
    ) -> str:
        """Get items in a GitHub Project V2. Can filter by state OR a single custom field=value.
    
        Args:
            owner: The GitHub organization or user name
            project_number: The project number
            limit: Maximum number of items to return (default: 50). When filtering, the system automatically fetches more items to improve efficiency.
            state: Optional state filter (e.g., "OPEN", "CLOSED"). Applies to Issues/PRs.
            filter_field_name: Optional custom field name to filter by (e.g., "Status"). Currently supports SingleSelect and Iteration fields.
            filter_field_value: Optional custom field value to filter by (e.g., "In Development"). Uses case-insensitive matching.
            cursor: Optional cursor for pagination. Use value from previous results to get next page.
    
        Returns:
            A formatted string with item details.
        """
        if state and filter_field_name:
            return "Error: Cannot filter by both 'state' and a custom field ('filter_field_name') simultaneously."
    
        try:
            result = await github_client.get_project_items(
                owner,
                project_number,
                limit,
                state,
                filter_field_name,
                filter_field_value,
                cursor,
            )
    
            items = result["items"]
            page_info = result["pageInfo"]
            has_next_page = page_info.get("hasNextPage", False)
            end_cursor = page_info.get("endCursor")
    
            filter_desc = ""
            if state:
                filter_desc = f" (State: {state.upper()})"
            elif filter_field_name and filter_field_value:
                filter_desc = f" (Filter: {filter_field_name} = '{filter_field_value}')"
    
            pagination_desc = ""
            if cursor:
                pagination_desc = " (continued)"
    
            if not items:
                if cursor:
                    return f"No more items found in project #{project_number} for {owner}{filter_desc}"
                else:
                    # Add more context for debugging purposes when filtering returns no items
                    if filter_field_name and filter_field_value:
                        # Get available fields and options to help debug
                        try:
                            fields_details = await github_client.get_project_fields_details(
                                owner, project_number
                            )
                            field_info = None
    
                            # Try to find the field case-insensitively
                            for fname, finfo in fields_details.items():
                                if fname.lower() == filter_field_name.lower():
                                    field_info = finfo
                                    break
    
                            if field_info:
                                field_type = field_info.get("type", "Unknown")
                                if field_type == "ProjectV2SingleSelectField":
                                    available_options = list(
                                        field_info.get("options", {}).keys()
                                    )
                                    return (
                                        f"No items found in project #{project_number} for {owner}{filter_desc}\n\n"
                                        f"Debug info:\n"
                                        f"- Found field '{filter_field_name}' with type '{field_type}'\n"
                                        f"- Available options: {available_options}\n"
                                        f"- Note: Searched up to {limit} items. If the project has many items, some '{filter_field_value}' items might be beyond this scope.\n"
                                        f"- Tip: Try increasing the limit parameter or check if the field value spelling is correct."
                                    )
                                elif field_type == "ProjectV2IterationField":
                                    available_iterations = list(
                                        field_info.get("iterations", {}).keys()
                                    )
                                    return (
                                        f"No items found in project #{project_number} for {owner}{filter_desc}\n\n"
                                        f"Debug info:\n"
                                        f"- Found field '{filter_field_name}' with type '{field_type}'\n"
                                        f"- Available iterations: {available_iterations}\n"
                                        f"- Note: Searched up to {limit} items. If the project has many items, some '{filter_field_value}' items might be beyond this scope.\n"
                                        f"- Tip: Try increasing the limit parameter or check if the field value spelling is correct."
                                    )
                        except Exception as e:
                            logger.warning(
                                f"Could not get additional field details for debug info: {e}"
                            )
    
                    return f"No items found in project #{project_number} for {owner}{filter_desc}\n\nNote: If the project has many items, try increasing the limit parameter to search more thoroughly."
    
            # Format results
            result = f"Items in project #{project_number} for {owner}{filter_desc}{pagination_desc}:\n\n"
            for item in items:
                content = item.get("content", {})
                result += f"- Item ID: {item['id']}\n"
                item_type = content.get("__typename")
                repo_info = content.get("repository", {})
                repo_str = (
                    f"{repo_info.get('owner', {}).get('login')}/{repo_info.get('name')}"
                    if repo_info
                    else "N/A"
                )
    
                if item_type == "Issue":
                    result += f"  Type: Issue #{content.get('number')} ({repo_str})\n"
                    result += f"  Title: {content.get('title')}\n"
                    result += f"  State: {content.get('state')}\n"
                    result += f"  URL: {content.get('url')}\n"
                elif item_type == "PullRequest":
                    result += f"  Type: PR #{content.get('number')} ({repo_str})\n"
                    result += f"  Title: {content.get('title')}\n"
                    result += f"  State: {content.get('state')}\n"
                    result += f"  URL: {content.get('url')}\n"
                elif item_type == "DraftIssue":
                    # Include body for draft issues if available (check if client fetches it)
                    body = content.get("body", "")  # Assuming client might fetch body
                    result += f"  Type: Draft Issue ID: {content.get('id')}\n"
                    result += f"  Title: {content.get('title')}\n"
                    if body:
                        result += f"  Body: {body[:100]}...\n"  # Show preview
                else:
                    result += f"  Type: {item_type or 'Unknown'}\n"
                    result += f"  Content: {json.dumps(content)}\n"
    
                # Show processed field values
                if item.get("fieldValues"):
                    result += "  Field Values:\n"
                    for field_name, value in item["fieldValues"].items():
                        result += f"    - {field_name}: {value}\n"
                result += "\n"
    
            # Add pagination info
            if has_next_page and end_cursor:
                result += "\n--- Pagination ---\n"
                result += "More items available: Yes\n"
                result += f"Next page cursor: {end_cursor}\n"
                result += "To get the next page, use the cursor parameter:\n"
                result += f"cursor: {end_cursor}\n"
    
            return result
        except (
            GitHubClientError,
            ValueError,
        ) as e:  # Catch client errors and validation errors
            logger.error(
                f"Error getting items for project {owner}/{project_number} with filter: {e}"
            )
            return f"Error: Could not get items for project {owner}/{project_number}. Details: {e}"
  • The core helper function in GitHubClient that implements the logic to fetch project items via GraphQL, including dynamic query construction, field filtering (state or custom SingleSelect/Iteration fields with case-insensitive option matching), field value processing for multiple types, pagination support, and increased fetch limits for efficiency when filtering.
    async def get_project_items(
        self,
        owner: str,
        project_number: int,
        limit: int = 10,
        state: Optional[str] = None,
        filter_field_name: Optional[str] = None,
        filter_field_value: Optional[str] = None,
        cursor: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Get items in a GitHub Project V2, optionally filtering by state or a custom field value.
        Args:
            owner: The GitHub organization or user name
            project_number: The project number
            limit: Maximum number of items to return per page (default: 10)
            state: Optional state to filter items by (e.g., "OPEN", "CLOSED").
            filter_field_name: Optional name of a custom field to filter by (e.g., "Status").
            filter_field_value: Optional value of the custom field to filter by (e.g., "Backlog").
            cursor: Optional cursor for pagination (default: None for first page)
        Returns:
            Dictionary containing:
                - items: List of project items
                - pageInfo: Information about pagination (hasNextPage, endCursor)
        Raises:
            GitHubClientError: If project or items cannot be retrieved, or filter is invalid.
            ValueError: If filter parameters are invalid.
        """
        try:
            project_id = await self.get_project_node_id(owner, project_number)
        except GitHubClientError as e:
            logger.error(f"Cannot get items: {e}")
            raise
    
        # When filtering, we need to fetch more items since many will be excluded
        # Increase the fetch size to be more efficient
        fetch_limit = limit
        if filter_field_name and filter_field_value:
            # Be more aggressive but respect GitHub's 100 record limit
            fetch_limit = min(max(limit * 5, 50), 100)
            logger.debug(
                f"Filtering enabled: increasing fetch limit from {limit} to {fetch_limit}"
            )
    
        # Prepare variables dict before use
        variables: Dict[str, Any] = {"projectId": project_id, "first": fetch_limit}
    
        # Add cursor if provided (for pagination)
        after_clause = ""
        if cursor:
            variables["cursor"] = cursor
            after_clause = ", after: $cursor"
    
        # Base query definition including fragments
        field_values_fragment = """
        fragment FieldValuesFragment on ProjectV2ItemFieldValueConnection {
            nodes {
               ... on ProjectV2ItemFieldTextValue { __typename text field { ... on ProjectV2FieldCommon { name } } }
               ... on ProjectV2ItemFieldDateValue { __typename date field { ... on ProjectV2FieldCommon { name } } }
               ... on ProjectV2ItemFieldSingleSelectValue { __typename name field { ... on ProjectV2FieldCommon { name } } }
               ... on ProjectV2ItemFieldNumberValue { __typename number field { ... on ProjectV2FieldCommon { name } } }
               ... on ProjectV2ItemFieldIterationValue { __typename title startDate duration field { ... on ProjectV2FieldCommon { name } } }
            }
        }
        """
        content_fragment = """
        fragment ContentFragment on ProjectV2ItemContent {
           ... on Issue { __typename id number title state url repository { name owner { login } } }
           ... on PullRequest { __typename id number title state url repository { name owner { login } } }
           ... on DraftIssue { __typename id title body }
        }
        """
    
        # Build filter parameters if needed
        filter_conditions = []
    
        if state:
            if state.upper() not in ["OPEN", "CLOSED"]:
                raise ValueError("Invalid state filter. Must be 'OPEN' or 'CLOSED'.")
            # For state filtering, let's collect all items and filter afterwards
            # as the API has changed how filtering works
    
        # Variables for field-based filtering
        field_id_var = None
        option_id_var = None
    
        if filter_field_name and filter_field_value:
            try:
                all_fields = await self.get_project_fields_details(
                    owner, project_number
                )
                logger.debug(f"All fields available: {list(all_fields.keys())}")
    
                # First try exact match
                field_info = all_fields.get(filter_field_name)
                # If not found, try case-insensitive match
                if not field_info:
                    actual_field_name = self._find_case_insensitive_key(
                        all_fields, filter_field_name
                    )
                    if actual_field_name:
                        field_info = all_fields.get(actual_field_name)
                        logger.info(
                            f"Found field '{actual_field_name}' using case-insensitive match for '{filter_field_name}'"
                        )
    
                if not field_info:
                    raise ValueError(f"Field '{filter_field_name}' not found.")
                field_id = field_info["id"]
                field_type = field_info["type"]
    
                logger.debug(
                    f"Found field '{filter_field_name}' with ID {field_id} and type {field_type}"
                )
    
                if field_type == "ProjectV2SingleSelectField":
                    available_options = list(field_info.get("options", {}).keys())
                    logger.debug(
                        f"Available options for '{filter_field_name}': {available_options}"
                    )
    
                    # First try exact match
                    option_id = field_info.get("options", {}).get(filter_field_value)
                    # If not found, try case-insensitive match
                    if not option_id:
                        options = field_info.get("options", {})
                        actual_option_name = self._find_case_insensitive_key(
                            options, filter_field_value
                        )
                        if actual_option_name:
                            option_id = options.get(actual_option_name)
                            logger.info(
                                f"Found option '{actual_option_name}' using case-insensitive match for '{filter_field_value}'"
                            )
    
                    if not option_id:
                        raise ValueError(
                            f"Option '{filter_field_value}' not found for field '{filter_field_name}'. Available: {available_options}"
                        )
                    field_id_var = field_id
                    option_id_var = option_id
                    logger.debug(
                        f"Using field ID {field_id_var} with option ID {option_id_var} for filtering"
                    )
                elif field_type == "ProjectV2IterationField":
                    available_iterations = list(field_info.get("iterations", {}).keys())
                    logger.debug(
                        f"Available iterations for '{filter_field_name}': {available_iterations}"
                    )
    
                    # First try exact match
                    iteration_id = field_info.get("iterations", {}).get(
                        filter_field_value
                    )
                    # If not found, try case-insensitive match
                    if not iteration_id:
                        iterations = field_info.get("iterations", {})
                        actual_iteration_name = self._find_case_insensitive_key(
                            iterations, filter_field_value
                        )
                        if actual_iteration_name:
                            iteration_id = iterations.get(actual_iteration_name)
                            logger.info(
                                f"Found iteration '{actual_iteration_name}' using case-insensitive match for '{filter_field_value}'"
                            )
    
                    if not iteration_id:
                        raise ValueError(
                            f"Iteration '{filter_field_value}' not found for field '{filter_field_name}'. Available: {available_iterations}"
                        )
                    field_id_var = field_id
                    option_id_var = iteration_id
                    logger.debug(
                        f"Using field ID {field_id_var} with iteration ID {option_id_var} for filtering"
                    )
                else:
                    logger.warning(
                        f"Filtering by field type '{field_type}' is not yet implemented."
                    )
            except GitHubClientError as e:
                logger.error(f"Error during field lookup for filtering: {e}")
                raise
            except ValueError as e:
                logger.error(f"Invalid filter input: {e}")
                raise
    
        # Use single curly braces for GraphQL, not double curly braces for f-string
        query = f"""
        {field_values_fragment}
        {content_fragment}
        query GetProjectItems($projectId: ID!, $first: Int!{', $cursor: String' if cursor else ''}) {{
          node(id: $projectId) {{
            ... on ProjectV2 {{
              items(first: $first{after_clause}) {{
                pageInfo {{
                  hasNextPage
                  endCursor
                }}
                nodes {{
                  id
                  type
                  fieldValues(first: 20) {{ ...FieldValuesFragment }}
                  content {{ ...ContentFragment }}
                }}
              }}
            }}
          }}
        }}
        """
    
        logger.debug(f"Executing items query: {query} with vars: {variables}")
    
        try:
            result = await self.execute_query(query, variables)
            if result is None:
                logger.warning(
                    f"Query returned None result for project {owner}/{project_number}"
                )
                return {
                    "items": [],
                    "pageInfo": {"hasNextPage": False, "endCursor": None},
                }
    
            items_data = result.get("node", {}).get("items")
            if items_data is None:  # Check if items key exists, even if null
                if result.get("node") is None:
                    raise GitHubClientError(
                        f"Project node not found for {owner}/{project_number}"
                    )
                else:
                    logger.info(
                        f"No items found matching filter criteria for project {owner}/{project_number}"
                    )
                    return {
                        "items": [],
                        "pageInfo": {"hasNextPage": False, "endCursor": None},
                    }
    
            # Get pagination info
            page_info = items_data.get("pageInfo", {})
            items = items_data.get("nodes", [])
    
            logger.debug(
                f"Retrieved {len(items)} items from project {owner}/{project_number}"
            )
    
            # Process field values
            filtered_items = []
            for item in items:
                if item.get("fieldValues") and item["fieldValues"].get("nodes"):
                    field_values = item["fieldValues"]["nodes"]
    
                    processed_values = {}
                    matches_field_filter = (
                        False if (field_id_var and option_id_var) else True
                    )
    
                    for fv in field_values:
                        raw_field_name = fv.get("field", {}).get("name")
                        # Sanitize the field name
                        if raw_field_name:
                            # Remove chars other than alphanumeric, space, underscore, hyphen
                            sanitized_field_name = re.sub(
                                r"[^\w\s-]", "", raw_field_name
                            ).strip()
                        else:
                            sanitized_field_name = "UnknownField"
    
                        field_name = (
                            sanitized_field_name or "UnnamedField"
                        )  # Ensure not empty
    
                        value = "N/A"
                        fv_type = fv.get("__typename")
                        if fv_type == "ProjectV2ItemFieldTextValue":
                            value = fv.get("text", "N/A")
                        elif fv_type == "ProjectV2ItemFieldDateValue":
                            value = fv.get("date", "N/A")
                        elif fv_type == "ProjectV2ItemFieldSingleSelectValue":
                            value = fv.get("name", "N/A")
                            # Check if this is the field we're filtering on
                            if (
                                field_id_var
                                and option_id_var
                                and field_name
                                and filter_field_name
                                and field_name.lower() == filter_field_name.lower()
                                and value
                                and filter_field_value
                                and value.lower() == filter_field_value.lower()
                            ):
                                matches_field_filter = True
                                logger.debug(
                                    f"Found matching item with field '{field_name}' = '{value}'"
                                )
                            elif (
                                field_id_var
                                and option_id_var
                                and field_name
                                and filter_field_name
                                and field_name.lower() == filter_field_name.lower()
                            ):
                                logger.debug(
                                    f"Field name matched but value did not: '{value}' != '{filter_field_value}'"
                                )
                        elif fv_type == "ProjectV2ItemFieldNumberValue":
                            value = fv.get("number", "N/A")
                        elif fv_type == "ProjectV2ItemFieldIterationValue":
                            title = fv.get("title", "N/A")
                            value = f"{title} (Start: {fv.get('startDate', 'N/A')})"
                            # Check if this is the field we're filtering on
                            if (
                                field_id_var
                                and option_id_var
                                and field_name
                                and filter_field_name
                                and field_name.lower() == filter_field_name.lower()
                                and title
                                and filter_field_value
                                and title.lower() == filter_field_value.lower()
                            ):
                                matches_field_filter = True
                                logger.debug(
                                    f"Found matching item with iteration field '{field_name}' = '{title}'"
                                )
                        processed_values[field_name] = value
    
                    item["fieldValues"] = processed_values
    
                    # Apply state filter if needed
                    matches_state_filter = True
                    if state and item.get("content"):
                        content_state = item["content"].get("state")
                        if content_state and content_state != state.upper():
                            matches_state_filter = False
    
                    if matches_field_filter and matches_state_filter:
                        filtered_items.append(item)
                else:
                    # Items without field values are included only if we're not doing field filtering
                    if not (field_id_var and option_id_var):
                        filtered_items.append(item)
    
            # When filtering, we may have fetched more than requested, so trim to the requested limit
            if filter_field_name and filter_field_value and len(filtered_items) > limit:
                filtered_items = filtered_items[:limit]
                # Update pagination info to indicate there may be more filtered results
                page_info = {
                    "hasNextPage": True,
                    "endCursor": page_info.get("endCursor"),
                }
    
            # Emergency check: if we're filtering and got very few results, warn that there might be more
            if (
                filter_field_name
                and filter_field_value
                and len(filtered_items) == 0
                and len(items) >= fetch_limit
                and fetch_limit < 100
            ):
                logger.warning(
                    f"Found 0 filtered items but fetched the maximum ({fetch_limit}). There might be more items beyond this limit. Consider increasing the search scope."
                )
    
            logger.debug(
                f"Filtered down to {len(filtered_items)} items for project {owner}/{project_number} using criteria field_name={filter_field_name}, field_value={filter_field_value}"
            )
            return {"items": filtered_items, "pageInfo": page_info}
        except GitHubClientError as e:
            logger.error(
                f"Failed to get items for project {owner}/{project_number}: {e}"
            )
            raise
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden. It discloses some behavioral traits: filtering logic (state OR custom field), pagination via cursor, case-insensitive matching for custom fields, and automatic fetching of more items when filtering. However, it doesn't cover important aspects like authentication requirements, rate limits, error conditions, or what happens with invalid parameters.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is well-structured with a clear purpose statement followed by organized 'Args' and 'Returns' sections. Every sentence adds value, though the 'When filtering...' note could be more concise. It's appropriately sized for a 7-parameter tool with complex filtering logic.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity (7 parameters, filtering logic, pagination) and lack of annotations/output schema, the description does a good job covering essential information. It explains parameters thoroughly, describes the filtering approach, mentions pagination, and specifies the return format. However, it could better address authentication, error handling, or relationship to sibling tools.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The description provides excellent parameter semantics beyond the schema. With 0% schema description coverage, it fully compensates by explaining all 7 parameters: their purposes (e.g., 'owner: The GitHub organization or user name'), constraints (e.g., 'Currently supports SingleSelect and Iteration fields'), default values (e.g., 'limit: Maximum number of items to return (default: 50)'), and behavioral implications (e.g., 'When filtering, the system automatically fetches more items to improve efficiency').

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: 'Get items in a GitHub Project V2' with filtering capabilities. It specifies the resource (GitHub Project V2 items) and action (get/filter), but doesn't explicitly differentiate from sibling tools like 'list_projects' or 'get_project_fields'.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description implies usage context through filtering options ('Can filter by state OR a single custom field=value') and mentions 'When filtering, the system automatically fetches more items to improve efficiency.' However, it doesn't provide explicit guidance on when to use this tool versus alternatives like 'list_projects' or 'get_project_fields', nor does it mention prerequisites or exclusions.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Arclio/github-projects-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server