mcp-github-crunchtools 1.3.1__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.1 → mcp_github_crunchtools-1.4.0}/CHANGELOG.md +23 -0
  2. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/CLAUDE.md +3 -3
  3. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/Containerfile +1 -1
  4. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/PKG-INFO +8 -4
  5. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/README.md +7 -3
  6. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/pyproject.toml +1 -1
  7. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/server.json +2 -2
  8. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/__init__.py +1 -1
  9. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/server.py +45 -9
  10. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/__init__.py +2 -0
  11. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/issues.py +57 -3
  12. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/pull_requests.py +13 -1
  13. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/tests/test_tools.py +115 -2
  14. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/uv.lock +1 -1
  15. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.gatehouse-ignore +0 -0
  16. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  18. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/dependabot.yml +0 -0
  20. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/ashigaru.yml +0 -0
  21. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/ci.yml +0 -0
  22. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/constitution.yml +0 -0
  23. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/container.yml +0 -0
  24. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/dependabot-automerge.yml +0 -0
  25. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/gatehouse-retriage.yml +0 -0
  26. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/gatehouse.yml +0 -0
  27. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/publish.yml +0 -0
  28. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.github/workflows/security.yml +0 -0
  29. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.gitignore +0 -0
  30. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.pre-commit-config.yaml +0 -0
  31. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.specify/memory/constitution.md +0 -0
  32. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.specify/specs/000-baseline/spec.md +0 -0
  33. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.specify/specs/001-pr-merge-logs-release/spec.md +0 -0
  34. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.specify/specs/002-request-token-merge-gate/spec.md +0 -0
  35. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.specify/templates/plan-template.md +0 -0
  36. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/.specify/templates/spec-template.md +0 -0
  37. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/LICENSE +0 -0
  38. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/SECURITY.md +0 -0
  39. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/gourmand-exceptions.toml +0 -0
  40. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/gourmand.toml +0 -0
  41. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/__main__.py +0 -0
  42. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/client.py +0 -0
  43. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/config.py +0 -0
  44. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/errors.py +0 -0
  45. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/models.py +0 -0
  46. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/actions.py +0 -0
  47. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/files.py +0 -0
  48. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/releases.py +0 -0
  49. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/src/mcp_github_crunchtools/tools/search.py +0 -0
  50. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/tests/__init__.py +0 -0
  51. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/tests/conftest.py +0 -0
  52. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/tests/test_merge_gate.py +0 -0
  53. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/tests/test_request_token.py +0 -0
  54. {mcp_github_crunchtools-1.3.1 → mcp_github_crunchtools-1.4.0}/tests/test_validation.py +0 -0
@@ -6,6 +6,29 @@ 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
+
24
+ ## [1.3.2] - 2026-10-17
25
+
26
+ ### Fixed
27
+
28
+ - The integer ID parameters of the read tools (`issue_number`, `pull_number`,
29
+ `run_id`) now publish `minimum: 1` in their schema, so `0` and negative IDs
30
+ are rejected.
31
+
9
32
  ## [1.3.1] - 2026-10-16
10
33
 
11
34
  ### 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.0" \
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.1
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.1"
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.1"
32
+ __version__ = "1.4.0"
33
33
  __all__ = ["main", "mcp"]
34
34
 
35
35
 
@@ -4,9 +4,10 @@ This module creates and configures the MCP server with all tools.
4
4
  """
5
5
 
6
6
  import logging
7
- from typing import Any
7
+ from typing import Annotated, Any
8
8
 
9
9
  from fastmcp import FastMCP
10
+ from pydantic import Field
10
11
 
11
12
  from .tools import (
12
13
  create_issue,
@@ -20,6 +21,7 @@ from .tools import (
20
21
  get_pull_request_checks,
21
22
  get_pull_request_diff,
22
23
  get_workflow_run,
24
+ list_issue_comments,
23
25
  list_issues,
24
26
  list_pull_request_comments,
25
27
  list_pull_requests,
@@ -44,7 +46,7 @@ READ_ONLY = {"readOnlyHint": True}
44
46
 
45
47
  mcp = FastMCP(
46
48
  name="mcp-github",
47
- version="1.3.1",
49
+ version="1.4.0",
48
50
  instructions=(
49
51
  "Secure MCP server for GitHub repositories: issues, pull requests "
50
52
  "(open, review discussion, diffs, CI checks, merge), Actions runs and "
@@ -89,7 +91,7 @@ async def list_issues_tool(
89
91
  @mcp.tool(annotations=READ_ONLY)
90
92
  async def get_issue_tool(
91
93
  repo: str,
92
- issue_number: int,
94
+ issue_number: Annotated[int, Field(ge=1)],
93
95
  owner: str | None = None,
94
96
  ) -> dict[str, Any]:
95
97
  """Get a single GitHub issue by number.
@@ -105,6 +107,40 @@ async def get_issue_tool(
105
107
  return await get_issue(owner=owner, repo=repo, issue_number=issue_number)
106
108
 
107
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
+
108
144
  @mcp.tool()
109
145
  async def create_issue_tool(
110
146
  repo: str,
@@ -222,7 +258,7 @@ async def list_pull_requests_tool(
222
258
  @mcp.tool(annotations=READ_ONLY)
223
259
  async def get_pull_request_tool(
224
260
  repo: str,
225
- pull_number: int,
261
+ pull_number: Annotated[int, Field(ge=1)],
226
262
  owner: str | None = None,
227
263
  ) -> dict[str, Any]:
228
264
  """Get a single GitHub pull request by number.
@@ -241,7 +277,7 @@ async def get_pull_request_tool(
241
277
  @mcp.tool(annotations=READ_ONLY)
242
278
  async def get_pull_request_diff_tool(
243
279
  repo: str,
244
- pull_number: int,
280
+ pull_number: Annotated[int, Field(ge=1)],
245
281
  owner: str | None = None,
246
282
  ) -> dict[str, Any]:
247
283
  """Get the unified diff for a GitHub pull request.
@@ -260,7 +296,7 @@ async def get_pull_request_diff_tool(
260
296
  @mcp.tool(annotations=READ_ONLY)
261
297
  async def get_pull_request_checks_tool(
262
298
  repo: str,
263
- pull_number: int,
299
+ pull_number: Annotated[int, Field(ge=1)],
264
300
  owner: str | None = None,
265
301
  ) -> dict[str, Any]:
266
302
  """Get a CI verdict for a PR that distinguishes skipped from failed.
@@ -399,7 +435,7 @@ async def merge_pull_request_tool(
399
435
  @mcp.tool(annotations=READ_ONLY)
400
436
  async def list_pull_request_comments_tool(
401
437
  repo: str,
402
- pull_number: int,
438
+ pull_number: Annotated[int, Field(ge=1)],
403
439
  owner: str | None = None,
404
440
  ) -> dict[str, Any]:
405
441
  """List everything said on a GitHub pull request, in one list.
@@ -650,7 +686,7 @@ async def rerun_failed_jobs_tool(
650
686
  @mcp.tool(annotations=READ_ONLY)
651
687
  async def get_workflow_run_tool(
652
688
  repo: str,
653
- run_id: int,
689
+ run_id: Annotated[int, Field(ge=1)],
654
690
  owner: str | None = None,
655
691
  ) -> dict[str, Any]:
656
692
  """Get one GitHub Actions workflow run and its jobs (no logs).
@@ -670,7 +706,7 @@ async def get_workflow_run_tool(
670
706
  @mcp.tool(annotations=READ_ONLY)
671
707
  async def get_failed_job_logs_tool(
672
708
  repo: str,
673
- run_id: int,
709
+ run_id: Annotated[int, Field(ge=1)],
674
710
  tail_lines: int = 200,
675
711
  owner: str | None = None,
676
712
  ) -> dict[str, Any]:
@@ -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},
@@ -140,6 +142,20 @@ class TestReadOnlyAnnotation:
140
142
  def test_every_read_only_tool_has_call_arguments(self) -> None:
141
143
  assert set(READ_ONLY_CALLS) == READ_ONLY
142
144
 
145
+ @pytest.mark.asyncio
146
+ async def test_read_tool_integer_ids_publish_minimum_one(self) -> None:
147
+ from mcp_github_crunchtools.server import mcp
148
+
149
+ found = 0
150
+ for tool in await mcp.list_tools():
151
+ if tool.name not in READ_ONLY:
152
+ continue
153
+ for field in ("issue_number", "pull_number", "run_id"):
154
+ if field in tool.parameters["properties"]:
155
+ found += 1
156
+ assert tool.parameters["properties"][field]["minimum"] == 1
157
+ assert found == 8
158
+
143
159
  @pytest.mark.asyncio
144
160
  @pytest.mark.parametrize("name", sorted(READ_ONLY))
145
161
  async def test_read_only_tool_sends_only_get(self, name: str) -> None:
@@ -384,6 +400,69 @@ class TestIssueTools:
384
400
 
385
401
  assert result["body"] == "Thanks!"
386
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
+
387
466
  @pytest.mark.asyncio
388
467
  async def test_create_issue(self) -> None:
389
468
  from mcp_github_crunchtools.tools import create_issue
@@ -902,6 +981,40 @@ class TestPullRequestTools:
902
981
  with _patch_client(resp), pytest.raises(NotFoundError):
903
982
  await list_pull_request_comments(owner="o", repo="r", pull_number=5)
904
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
+
905
1018
  @pytest.mark.asyncio
906
1019
  async def test_reply_to_review_comment(self) -> None:
907
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.1"
925
+ version = "1.4.0"
926
926
  source = { editable = "." }
927
927
  dependencies = [
928
928
  { name = "fastmcp" },