mcp-github-crunchtools 1.3.2__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/CHANGELOG.md +15 -0
  2. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/CLAUDE.md +3 -3
  3. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/Containerfile +1 -1
  4. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/PKG-INFO +8 -4
  5. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/README.md +7 -3
  6. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/pyproject.toml +1 -1
  7. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/server.json +2 -2
  8. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/__init__.py +1 -1
  9. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/server.py +36 -1
  10. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/__init__.py +2 -0
  11. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/issues.py +57 -3
  12. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/pull_requests.py +13 -1
  13. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/tests/test_tools.py +102 -3
  14. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/uv.lock +1 -1
  15. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.gatehouse-ignore +0 -0
  16. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  18. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/dependabot.yml +0 -0
  20. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/ashigaru.yml +0 -0
  21. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/ci.yml +0 -0
  22. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/constitution.yml +0 -0
  23. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/container.yml +0 -0
  24. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/dependabot-automerge.yml +0 -0
  25. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/gatehouse-retriage.yml +0 -0
  26. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/gatehouse.yml +0 -0
  27. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/publish.yml +0 -0
  28. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.github/workflows/security.yml +0 -0
  29. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.gitignore +0 -0
  30. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.pre-commit-config.yaml +0 -0
  31. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.specify/memory/constitution.md +0 -0
  32. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.specify/specs/000-baseline/spec.md +0 -0
  33. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.specify/specs/001-pr-merge-logs-release/spec.md +0 -0
  34. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.specify/specs/002-request-token-merge-gate/spec.md +0 -0
  35. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.specify/templates/plan-template.md +0 -0
  36. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/.specify/templates/spec-template.md +0 -0
  37. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/LICENSE +0 -0
  38. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/SECURITY.md +0 -0
  39. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/gourmand-exceptions.toml +0 -0
  40. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/gourmand.toml +0 -0
  41. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/__main__.py +0 -0
  42. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/client.py +0 -0
  43. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/config.py +0 -0
  44. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/errors.py +0 -0
  45. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/models.py +0 -0
  46. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/actions.py +0 -0
  47. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/files.py +0 -0
  48. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/releases.py +0 -0
  49. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/search.py +0 -0
  50. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/tests/__init__.py +0 -0
  51. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/tests/conftest.py +0 -0
  52. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/tests/test_merge_gate.py +0 -0
  53. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/tests/test_request_token.py +0 -0
  54. {mcp_github_crunchtools-1.3.2 → mcp_github_crunchtools-1.4.0}/tests/test_validation.py +0 -0
@@ -6,6 +6,21 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.4.0] - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - `list_issue_comments_tool`: the comments on an issue or a pull request
14
+ conversation, oldest first, one page at a time, with id, author,
15
+ author_association, created_at, updated_at, body and html_url (#45).
16
+
17
+ ### Fixed
18
+
19
+ - `list_pull_request_comments_tool` called on a plain issue returned GitHub's
20
+ 403 "Resource not accessible by personal access token", which reads like a
21
+ token scope problem. It now says the number is an issue and points to
22
+ `list_issue_comments_tool` (#45).
23
+
9
24
  ## [1.3.2] - 2026-10-17
10
25
 
11
26
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  # Claude Code Instructions
2
2
 
3
- Secure MCP server for the GitHub REST API with 25 tools across issues, pull requests, Actions, releases, files, and search. Works with github.com and GitHub Enterprise Server.
3
+ Secure MCP server for the GitHub REST API with 26 tools across issues, pull requests, Actions, releases, files, and search. Works with github.com and GitHub Enterprise Server.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -51,11 +51,11 @@ claude mcp add mcp-github-crunchtools \
51
51
  | `SSL_CERT_FILE` | No | — | Custom CA bundle for self-hosted instances |
52
52
  | `GITHUB_SSL_VERIFY` | No | `true` | Set `false` to disable SSL verification |
53
53
 
54
- ## Available Tools (25)
54
+ ## Available Tools (26)
55
55
 
56
56
  | Category | Tools |
57
57
  |----------|-------|
58
- | Issues | `list_issues_tool`, `get_issue_tool`, `create_issue_tool`, `create_issue_comment_tool`, `update_issue_tool` |
58
+ | Issues | `list_issues_tool`, `get_issue_tool`, `list_issue_comments_tool`, `create_issue_tool`, `create_issue_comment_tool`, `update_issue_tool` |
59
59
  | Pull Requests | `list_pull_requests_tool`, `get_pull_request_tool`, `get_pull_request_diff_tool`, `get_pull_request_checks_tool`, `list_pull_request_comments_tool`, `create_pull_request_tool`, `update_pull_request_tool`, `reply_to_review_comment_tool`, `merge_pull_request_tool` |
60
60
  | Files | `get_file_content_tool`, `list_repo_tree_tool` |
61
61
  | Search | `search_code_tool`, `search_issues_tool` |
@@ -27,7 +27,7 @@ FROM quay.io/hummingbird/python:latest
27
27
 
28
28
  # Labels for container metadata
29
29
  LABEL name="mcp-github-crunchtools" \
30
- version="1.3.2" \
30
+ version="1.4.0" \
31
31
  summary="Secure MCP server for GitHub issues, pull requests, Actions, releases, files, and search" \
32
32
  description="A security-focused MCP server for GitHub built on Red Hat UBI" \
33
33
  maintainer="crunchtools.com" \
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-github-crunchtools
3
- Version: 1.3.2
3
+ Version: 1.4.0
4
4
  Summary: Secure MCP server for GitHub issues, pull requests, Actions, releases, files, and search
5
5
  Author: crunchtools.com
6
6
  License-Expression: AGPL-3.0-or-later
@@ -60,7 +60,7 @@ The container image is built on the [Hummingbird Python base image](https://quay
60
60
 
61
61
  ## Features
62
62
 
63
- 25 tools. Tools marked *write* change something on GitHub; the others only
63
+ 26 tools. Tools marked *write* change something on GitHub; the others only
64
64
  read and publish `readOnlyHint`. The last column is the fine-grained token
65
65
  permission the tool needs (see [Token permissions](#token-permissions)).
66
66
 
@@ -70,6 +70,7 @@ permission the tool needs (see [Token permissions](#token-permissions)).
70
70
  |------|--------------|------------|
71
71
  | `list_issues_tool` | List issues for a repository (pull requests excluded) | Issues: read |
72
72
  | `get_issue_tool` | Get a single issue by number | Issues: read |
73
+ | `list_issue_comments_tool` | Comments on an issue or a pull request conversation, oldest first | Issues: read, or Pull requests: read for a PR |
73
74
  | `create_issue_tool` | Open an issue (*write*) | Issues: write |
74
75
  | `create_issue_comment_tool` | Comment on an issue or pull request (*write*) | Issues: write, or Pull requests: write for a PR |
75
76
  | `update_issue_tool` | Edit, close or reopen an issue (*write*) | Issues: write |
@@ -123,7 +124,10 @@ permission the tool needs (see [Token permissions](#token-permissions)).
123
124
 
124
125
  - `list_pull_request_comments_tool` reads each of its three kinds oldest
125
126
  first, up to 300 items per kind. `truncated` and `truncated_kinds` say when
126
- newer items were left out.
127
+ newer items were left out. Called on a plain issue it says so; use
128
+ `list_issue_comments_tool` there.
129
+ - `list_issue_comments_tool` returns one page (up to 100 comments); follow
130
+ `pagination.next_page` for more.
127
131
  - `get_workflow_run_tool` lists up to 300 jobs; `jobs_truncated` says when the
128
132
  run has more.
129
133
  - `get_failed_job_logs_tool` returns at most 1000 lines per job (200 by
@@ -218,7 +222,7 @@ For a fine-grained personal access token, the repository permissions are:
218
222
  |------------|--------------------|---------------------|
219
223
  | Metadata | every tool (GitHub grants it with any other permission) | — |
220
224
  | Contents | `get_file_content_tool`, `list_repo_tree_tool` | `merge_pull_request_tool`, `create_release_tool` |
221
- | Issues | `list_issues_tool`, `get_issue_tool` | `create_issue_tool`, `update_issue_tool`, `create_issue_comment_tool` |
225
+ | Issues | `list_issues_tool`, `get_issue_tool`, `list_issue_comments_tool` | `create_issue_tool`, `update_issue_tool`, `create_issue_comment_tool` |
222
226
  | Pull requests | `merge_pull_request_tool`, `list_pull_requests_tool`, `get_pull_request_tool`, `get_pull_request_diff_tool`, `get_pull_request_checks_tool`, `list_pull_request_comments_tool` | `create_pull_request_tool`, `update_pull_request_tool`, `reply_to_review_comment_tool`, `create_issue_comment_tool` on a PR |
223
227
  | Actions | `list_workflow_runs_tool`, `get_workflow_run_tool`, `get_failed_job_logs_tool` | `trigger_workflow_tool`, `rerun_workflow_run_tool`, `rerun_failed_jobs_tool` |
224
228
  | Commit statuses | `get_pull_request_checks_tool`, `merge_pull_request_tool` | — |
@@ -34,7 +34,7 @@ The container image is built on the [Hummingbird Python base image](https://quay
34
34
 
35
35
  ## Features
36
36
 
37
- 25 tools. Tools marked *write* change something on GitHub; the others only
37
+ 26 tools. Tools marked *write* change something on GitHub; the others only
38
38
  read and publish `readOnlyHint`. The last column is the fine-grained token
39
39
  permission the tool needs (see [Token permissions](#token-permissions)).
40
40
 
@@ -44,6 +44,7 @@ permission the tool needs (see [Token permissions](#token-permissions)).
44
44
  |------|--------------|------------|
45
45
  | `list_issues_tool` | List issues for a repository (pull requests excluded) | Issues: read |
46
46
  | `get_issue_tool` | Get a single issue by number | Issues: read |
47
+ | `list_issue_comments_tool` | Comments on an issue or a pull request conversation, oldest first | Issues: read, or Pull requests: read for a PR |
47
48
  | `create_issue_tool` | Open an issue (*write*) | Issues: write |
48
49
  | `create_issue_comment_tool` | Comment on an issue or pull request (*write*) | Issues: write, or Pull requests: write for a PR |
49
50
  | `update_issue_tool` | Edit, close or reopen an issue (*write*) | Issues: write |
@@ -97,7 +98,10 @@ permission the tool needs (see [Token permissions](#token-permissions)).
97
98
 
98
99
  - `list_pull_request_comments_tool` reads each of its three kinds oldest
99
100
  first, up to 300 items per kind. `truncated` and `truncated_kinds` say when
100
- newer items were left out.
101
+ newer items were left out. Called on a plain issue it says so; use
102
+ `list_issue_comments_tool` there.
103
+ - `list_issue_comments_tool` returns one page (up to 100 comments); follow
104
+ `pagination.next_page` for more.
101
105
  - `get_workflow_run_tool` lists up to 300 jobs; `jobs_truncated` says when the
102
106
  run has more.
103
107
  - `get_failed_job_logs_tool` returns at most 1000 lines per job (200 by
@@ -192,7 +196,7 @@ For a fine-grained personal access token, the repository permissions are:
192
196
  |------------|--------------------|---------------------|
193
197
  | Metadata | every tool (GitHub grants it with any other permission) | — |
194
198
  | Contents | `get_file_content_tool`, `list_repo_tree_tool` | `merge_pull_request_tool`, `create_release_tool` |
195
- | Issues | `list_issues_tool`, `get_issue_tool` | `create_issue_tool`, `update_issue_tool`, `create_issue_comment_tool` |
199
+ | Issues | `list_issues_tool`, `get_issue_tool`, `list_issue_comments_tool` | `create_issue_tool`, `update_issue_tool`, `create_issue_comment_tool` |
196
200
  | Pull requests | `merge_pull_request_tool`, `list_pull_requests_tool`, `get_pull_request_tool`, `get_pull_request_diff_tool`, `get_pull_request_checks_tool`, `list_pull_request_comments_tool` | `create_pull_request_tool`, `update_pull_request_tool`, `reply_to_review_comment_tool`, `create_issue_comment_tool` on a PR |
197
201
  | Actions | `list_workflow_runs_tool`, `get_workflow_run_tool`, `get_failed_job_logs_tool` | `trigger_workflow_tool`, `rerun_workflow_run_tool`, `rerun_failed_jobs_tool` |
198
202
  | Commit statuses | `get_pull_request_checks_tool`, `merge_pull_request_tool` | — |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-github-crunchtools"
3
- version = "1.3.2"
3
+ version = "1.4.0"
4
4
  description = "Secure MCP server for GitHub issues, pull requests, Actions, releases, files, and search"
5
5
  requires-python = ">=3.10"
6
6
  license = "AGPL-3.0-or-later"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/crunchtools/mcp-github",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.3.0",
10
+ "version": "1.4.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "mcp-github-crunchtools",
15
- "version": "1.3.0",
15
+ "version": "1.4.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -29,7 +29,7 @@ import argparse
29
29
 
30
30
  from .server import mcp
31
31
 
32
- __version__ = "1.3.2"
32
+ __version__ = "1.4.0"
33
33
  __all__ = ["main", "mcp"]
34
34
 
35
35
 
@@ -21,6 +21,7 @@ from .tools import (
21
21
  get_pull_request_checks,
22
22
  get_pull_request_diff,
23
23
  get_workflow_run,
24
+ list_issue_comments,
24
25
  list_issues,
25
26
  list_pull_request_comments,
26
27
  list_pull_requests,
@@ -45,7 +46,7 @@ READ_ONLY = {"readOnlyHint": True}
45
46
 
46
47
  mcp = FastMCP(
47
48
  name="mcp-github",
48
- version="1.3.2",
49
+ version="1.4.0",
49
50
  instructions=(
50
51
  "Secure MCP server for GitHub repositories: issues, pull requests "
51
52
  "(open, review discussion, diffs, CI checks, merge), Actions runs and "
@@ -106,6 +107,40 @@ async def get_issue_tool(
106
107
  return await get_issue(owner=owner, repo=repo, issue_number=issue_number)
107
108
 
108
109
 
110
+ @mcp.tool(annotations=READ_ONLY)
111
+ async def list_issue_comments_tool(
112
+ repo: str,
113
+ issue_number: Annotated[int, Field(ge=1)],
114
+ owner: str | None = None,
115
+ per_page: int = 30,
116
+ page: int = 1,
117
+ ) -> dict[str, Any]:
118
+ """List the comments on a GitHub issue or pull request conversation, oldest first.
119
+
120
+ Works for plain issues, which list_pull_request_comments_tool does not.
121
+ On a pull request it returns only the conversation comments; diff
122
+ comments and reviews come from list_pull_request_comments_tool.
123
+
124
+ Args:
125
+ repo: Repository name
126
+ issue_number: Issue or pull request number
127
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
128
+ per_page: Results per page, max 100 (default: 30)
129
+ page: Page number (default: 1)
130
+
131
+ Returns:
132
+ {"issue_number", "items", "pagination"?}; each item carries id,
133
+ author, author_association, created_at, updated_at, body, html_url
134
+ """
135
+ return await list_issue_comments(
136
+ owner=owner,
137
+ repo=repo,
138
+ issue_number=issue_number,
139
+ per_page=per_page,
140
+ page=page,
141
+ )
142
+
143
+
109
144
  @mcp.tool()
110
145
  async def create_issue_tool(
111
146
  repo: str,
@@ -16,6 +16,7 @@ from .issues import (
16
16
  create_issue,
17
17
  create_issue_comment,
18
18
  get_issue,
19
+ list_issue_comments,
19
20
  list_issues,
20
21
  update_issue,
21
22
  )
@@ -45,6 +46,7 @@ __all__ = [
45
46
  "get_pull_request_checks",
46
47
  "get_pull_request_diff",
47
48
  "get_workflow_run",
49
+ "list_issue_comments",
48
50
  "list_issues",
49
51
  "list_pull_request_comments",
50
52
  "list_pull_requests",
@@ -88,6 +88,62 @@ async def get_issue(
88
88
  return await client.get(f"/repos/{owner}/{repo}/issues/{issue_number}")
89
89
 
90
90
 
91
+ async def list_issue_comments(
92
+ owner: str | None,
93
+ repo: str,
94
+ issue_number: int,
95
+ per_page: int = 30,
96
+ page: int = 1,
97
+ ) -> dict[str, Any]:
98
+ """List the comments on an issue or a pull request conversation, oldest first.
99
+
100
+ Diff comments and reviews on a pull request are not included; use
101
+ list_pull_request_comments for those.
102
+
103
+ Args:
104
+ owner: Repository owner (defaults to GITHUB_DEFAULT_ORG if unset)
105
+ repo: Repository name
106
+ issue_number: Issue or pull request number
107
+ per_page: Results per page (max 100)
108
+ page: Page number
109
+
110
+ Returns:
111
+ {"issue_number", "items", "pagination"?}; each item carries id,
112
+ author, author_association, created_at, updated_at, body, html_url
113
+ """
114
+ owner = resolve_owner(owner)
115
+ repo = validate_name(repo, "repo")
116
+ issue_number = validate_positive_int(issue_number, "issue_number")
117
+
118
+ client = get_client()
119
+ result = await client.get(
120
+ f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
121
+ params={
122
+ "per_page": clamp_per_page(per_page),
123
+ "page": validate_positive_int(page, "page"),
124
+ },
125
+ )
126
+
127
+ response: dict[str, Any] = {
128
+ "issue_number": issue_number,
129
+ "items": [
130
+ {
131
+ "id": raw.get("id"),
132
+ "author": (raw.get("user") or {}).get("login"),
133
+ "author_association": raw.get("author_association"),
134
+ "created_at": raw.get("created_at"),
135
+ "updated_at": raw.get("updated_at"),
136
+ "body": raw.get("body") or "",
137
+ "html_url": raw.get("html_url"),
138
+ }
139
+ for raw in result.get("items", [])
140
+ ],
141
+ }
142
+ if "pagination" in result:
143
+ response["pagination"] = result["pagination"]
144
+ return response
145
+
146
+
91
147
  async def create_issue(
92
148
  owner: str | None,
93
149
  repo: str,
@@ -169,9 +225,7 @@ async def update_issue(
169
225
  json_data["state"] = state
170
226
  if state_reason is not None:
171
227
  if state_reason not in ("completed", "not_planned", "reopened"):
172
- raise ValidationError(
173
- "state_reason must be 'completed', 'not_planned', or 'reopened'"
174
- )
228
+ raise ValidationError("state_reason must be 'completed', 'not_planned', or 'reopened'")
175
229
  json_data["state_reason"] = state_reason
176
230
  if title is not None:
177
231
  json_data["title"] = title
@@ -728,7 +728,19 @@ async def list_pull_request_comments(
728
728
  items: list[dict[str, Any]] = []
729
729
  truncated_kinds: list[str] = []
730
730
  for kind, path in sources.items():
731
- raw_items, cut = await _collect_pages(client, path)
731
+ try:
732
+ raw_items, cut = await _collect_pages(client, path)
733
+ except (GitHubApiError, NotFoundError):
734
+ # GitHub answers /pulls/{n}/... for a plain issue with a 403 that
735
+ # reads like a token scope problem. Say what is actually wrong.
736
+ if kind != "issue_comment":
737
+ issue = await client.get(f"/repos/{owner}/{repo}/issues/{pull_number}")
738
+ if "pull_request" not in issue:
739
+ raise ValidationError(
740
+ f"#{pull_number} is an issue, not a pull request; "
741
+ "use list_issue_comments_tool"
742
+ ) from None
743
+ raise
732
744
  items.extend(_discussion_item(kind, raw) for raw in raw_items)
733
745
  if cut:
734
746
  truncated_kinds.append(kind)
@@ -38,16 +38,17 @@ class TestToolRegistration:
38
38
  assert callable(func), f"{name} is not callable"
39
39
 
40
40
  def test_tool_count(self) -> None:
41
- """Server should export exactly 25 tools."""
41
+ """Server should export exactly 26 tools."""
42
42
  from mcp_github_crunchtools.tools import __all__
43
43
 
44
- assert len(__all__) == 25
44
+ assert len(__all__) == 26
45
45
 
46
46
 
47
47
  READ_ONLY = frozenset(
48
48
  {
49
49
  "list_issues_tool",
50
50
  "get_issue_tool",
51
+ "list_issue_comments_tool",
51
52
  "list_pull_requests_tool",
52
53
  "get_pull_request_tool",
53
54
  "get_pull_request_diff_tool",
@@ -82,6 +83,7 @@ WRITES = frozenset(
82
83
  READ_ONLY_CALLS: dict[str, dict[str, object]] = {
83
84
  "list_issues_tool": {"owner": "o", "repo": "r"},
84
85
  "get_issue_tool": {"owner": "o", "repo": "r", "issue_number": 1},
86
+ "list_issue_comments_tool": {"owner": "o", "repo": "r", "issue_number": 1},
85
87
  "list_pull_requests_tool": {"owner": "o", "repo": "r"},
86
88
  "get_pull_request_tool": {"owner": "o", "repo": "r", "pull_number": 1},
87
89
  "get_pull_request_diff_tool": {"owner": "o", "repo": "r", "pull_number": 1},
@@ -152,7 +154,7 @@ class TestReadOnlyAnnotation:
152
154
  if field in tool.parameters["properties"]:
153
155
  found += 1
154
156
  assert tool.parameters["properties"][field]["minimum"] == 1
155
- assert found == 7
157
+ assert found == 8
156
158
 
157
159
  @pytest.mark.asyncio
158
160
  @pytest.mark.parametrize("name", sorted(READ_ONLY))
@@ -398,6 +400,69 @@ class TestIssueTools:
398
400
 
399
401
  assert result["body"] == "Thanks!"
400
402
 
403
+ @pytest.mark.asyncio
404
+ async def test_list_issue_comments(self) -> None:
405
+ from mcp_github_crunchtools.tools import list_issue_comments
406
+
407
+ resp = _mock_response(
408
+ json_data=[
409
+ {
410
+ "id": 7,
411
+ "user": {"login": "alice"},
412
+ "author_association": "MEMBER",
413
+ "created_at": "2026-10-01T10:00:00Z",
414
+ "updated_at": "2026-10-01T10:05:00Z",
415
+ "body": "Repro attached.",
416
+ "html_url": "https://github.com/o/r/issues/42#issuecomment-7",
417
+ "reactions": {"dropped": True},
418
+ },
419
+ {"id": 8, "user": None, "body": None},
420
+ ],
421
+ headers={"link": '<https://api.github.com/x?page=3>; rel="next"'},
422
+ )
423
+
424
+ with _patch_client(resp) as mock_client:
425
+ result = await list_issue_comments(
426
+ owner="o", repo="r", issue_number=42, per_page=500, page=2
427
+ )
428
+ call = mock_client.return_value.request.call_args
429
+ assert call.kwargs["method"] == "GET"
430
+ assert call.kwargs["url"] == "/repos/o/r/issues/42/comments"
431
+ assert call.kwargs["params"] == {"per_page": 100, "page": 2}
432
+
433
+ assert result["issue_number"] == 42
434
+ assert result["pagination"]["next_page"] == 3
435
+ assert result["items"][0] == {
436
+ "id": 7,
437
+ "author": "alice",
438
+ "author_association": "MEMBER",
439
+ "created_at": "2026-10-01T10:00:00Z",
440
+ "updated_at": "2026-10-01T10:05:00Z",
441
+ "body": "Repro attached.",
442
+ "html_url": "https://github.com/o/r/issues/42#issuecomment-7",
443
+ }
444
+ assert result["items"][1]["author"] is None
445
+ assert result["items"][1]["body"] == ""
446
+
447
+ @pytest.mark.asyncio
448
+ async def test_list_issue_comments_refuses_bad_number(self) -> None:
449
+ from mcp_github_crunchtools.tools import list_issue_comments
450
+
451
+ with _patch_client(_mock_response(json_data=[])) as mock_client:
452
+ with pytest.raises(ValueError, match="positive integer"):
453
+ await list_issue_comments(owner="o", repo="r", issue_number=0)
454
+ mock_client.return_value.request.assert_not_awaited()
455
+
456
+ @pytest.mark.asyncio
457
+ async def test_list_issue_comments_not_found(self) -> None:
458
+ from mcp_github_crunchtools.errors import NotFoundError
459
+ from mcp_github_crunchtools.tools import list_issue_comments
460
+
461
+ resp = _mock_response(status_code=404, json_data={"message": "Not Found"})
462
+
463
+ with _patch_client(resp), pytest.raises(NotFoundError):
464
+ await list_issue_comments(owner="o", repo="r", issue_number=42)
465
+
401
466
  @pytest.mark.asyncio
402
467
  async def test_create_issue(self) -> None:
403
468
  from mcp_github_crunchtools.tools import create_issue
@@ -916,6 +981,40 @@ class TestPullRequestTools:
916
981
  with _patch_client(resp), pytest.raises(NotFoundError):
917
982
  await list_pull_request_comments(owner="o", repo="r", pull_number=5)
918
983
 
984
+ @pytest.mark.asyncio
985
+ async def test_list_pull_request_comments_on_an_issue(self) -> None:
986
+ """GitHub's 403 for /pulls/{n} on an issue becomes a clear error."""
987
+ from mcp_github_crunchtools.errors import ValidationError
988
+ from mcp_github_crunchtools.tools import list_pull_request_comments
989
+
990
+ forbidden = _mock_response(
991
+ status_code=403,
992
+ json_data={"message": "Resource not accessible by personal access token"},
993
+ )
994
+ issue = _mock_response(json_data={"number": 5, "title": "A plain issue"})
995
+
996
+ with (
997
+ _patch_client_sequence(_mock_response(json_data=[]), forbidden, issue) as mock_client,
998
+ pytest.raises(ValidationError, match="#5 is an issue, not a pull request"),
999
+ ):
1000
+ await list_pull_request_comments(owner="o", repo="r", pull_number=5)
1001
+ calls = mock_client.return_value.request.call_args_list
1002
+ assert calls[-1].kwargs["url"] == "/repos/o/r/issues/5"
1003
+
1004
+ @pytest.mark.asyncio
1005
+ async def test_list_pull_request_comments_403_on_a_pr_passes_through(self) -> None:
1006
+ from mcp_github_crunchtools.errors import GitHubApiError
1007
+ from mcp_github_crunchtools.tools import list_pull_request_comments
1008
+
1009
+ forbidden = _mock_response(status_code=403, json_data={"message": "Forbidden"})
1010
+ pull = _mock_response(json_data={"number": 5, "pull_request": {"url": "x"}})
1011
+
1012
+ with (
1013
+ _patch_client_sequence(_mock_response(json_data=[]), forbidden, pull),
1014
+ pytest.raises(GitHubApiError, match="403"),
1015
+ ):
1016
+ await list_pull_request_comments(owner="o", repo="r", pull_number=5)
1017
+
919
1018
  @pytest.mark.asyncio
920
1019
  async def test_reply_to_review_comment(self) -> None:
921
1020
  from mcp_github_crunchtools.tools import reply_to_review_comment
@@ -922,7 +922,7 @@ wheels = [
922
922
 
923
923
  [[package]]
924
924
  name = "mcp-github-crunchtools"
925
- version = "1.3.2"
925
+ version = "1.4.0"
926
926
  source = { editable = "." }
927
927
  dependencies = [
928
928
  { name = "fastmcp" },