mcp-github-crunchtools 2.0.1__tar.gz → 2.1.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 (57) hide show
  1. mcp_github_crunchtools-2.1.0/.github/workflows/ci.yml +46 -0
  2. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/constitution.yml +1 -1
  3. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/container.yml +11 -3
  4. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/dependabot-automerge.yml +1 -1
  5. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/security.yml +1 -1
  6. mcp_github_crunchtools-2.1.0/.pre-commit-config.yaml +18 -0
  7. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/memory/constitution.md +4 -3
  8. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/CHANGELOG.md +44 -0
  9. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/Containerfile +1 -1
  10. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/PKG-INFO +1 -1
  11. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/gourmand-exceptions.toml +11 -0
  12. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/pyproject.toml +12 -1
  13. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/server.json +2 -2
  14. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/__init__.py +1 -1
  15. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/client.py +119 -20
  16. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/server.py +1 -1
  17. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/files.py +2 -6
  18. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/conftest.py +43 -21
  19. mcp_github_crunchtools-2.1.0/tests/test_http_contract.py +150 -0
  20. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/test_tools.py +33 -33
  21. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/uv.lock +58 -58
  22. mcp_github_crunchtools-2.0.1/.github/workflows/ci.yml +0 -57
  23. mcp_github_crunchtools-2.0.1/.pre-commit-config.yaml +0 -39
  24. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.gatehouse-ignore +0 -0
  25. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  26. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  27. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  28. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/dependabot.yml +0 -0
  29. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/ashigaru.yml +0 -0
  30. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/gatehouse-retriage.yml +0 -0
  31. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/gatehouse.yml +0 -0
  32. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/publish.yml +0 -0
  33. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.gitignore +0 -0
  34. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/specs/000-baseline/spec.md +0 -0
  35. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/specs/001-pr-merge-logs-release/spec.md +0 -0
  36. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/specs/002-request-token-merge-gate/spec.md +0 -0
  37. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/templates/plan-template.md +0 -0
  38. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/templates/spec-template.md +0 -0
  39. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/CLAUDE.md +0 -0
  40. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/LICENSE +0 -0
  41. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/README.md +0 -0
  42. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/SECURITY.md +0 -0
  43. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/gourmand.toml +0 -0
  44. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/__main__.py +0 -0
  45. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/config.py +0 -0
  46. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/errors.py +0 -0
  47. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/models.py +0 -0
  48. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/__init__.py +0 -0
  49. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/actions.py +0 -0
  50. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/issues.py +0 -0
  51. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/pull_requests.py +0 -0
  52. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/releases.py +0 -0
  53. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/search.py +0 -0
  54. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/__init__.py +0 -0
  55. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/test_merge_gate.py +0 -0
  56. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/test_request_token.py +0 -0
  57. {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/test_validation.py +0 -0
@@ -0,0 +1,46 @@
1
+ # The repo's own checks, from python-sdlc at the release .pre-commit-config.yaml
2
+ # pins -- the same image and the same commands the laptop runs, so a green push
3
+ # predicts a green CI (constitution XII, v1.30.0).
4
+ #
5
+ # NOT every check on this pull request: Gourmand and Gatehouse run as their own
6
+ # workflows, which is why `sdlc ci` leaves them out (XII: "CI runs push's checks
7
+ # except Gourmand and Gatehouse, which CI runs through their own workflows").
8
+ # The Gourmand job below is one of them.
9
+ #
10
+ # The job NAME `sdlc ci` is the required check in the org ruleset "Python SDLC
11
+ # gates". Renaming the job stops it gating, silently.
12
+
13
+ name: CI
14
+
15
+ on:
16
+ push:
17
+ branches: [main]
18
+ pull_request:
19
+ workflow_dispatch:
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ jobs:
25
+ sdlc:
26
+ name: sdlc ci
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v7
30
+ with:
31
+ persist-credentials: false
32
+
33
+ # The release is read from .pre-commit-config.yaml, so one line pins the
34
+ # launcher, the image and every tool in it, for the laptop and CI both.
35
+ - name: sdlc ci
36
+ run: |
37
+ set -eu
38
+ rev=$(awk '/repo: https:\/\/github.com\/crunchtools\/python-sdlc/ {f=1; next}
39
+ f && /rev:/ {print $2; exit}' .pre-commit-config.yaml)
40
+ [ -n "$rev" ] || { echo "no python-sdlc rev in .pre-commit-config.yaml" >&2; exit 2; }
41
+ git clone -q --depth 1 --branch "$rev" https://github.com/crunchtools/python-sdlc "$RUNNER_TEMP/sdlc"
42
+ $RUNNER_TEMP/sdlc/bin/sdlc ci
43
+
44
+ gourmand:
45
+ name: Code Quality (Gourmand)
46
+ uses: crunchtools/gatehouse/.github/workflows/gourmand.yml@v0
@@ -18,4 +18,4 @@ permissions:
18
18
  jobs:
19
19
  constitution:
20
20
  name: Constitution
21
- uses: crunchtools/constitution/.github/workflows/validate.yml@v1.28.0
21
+ uses: crunchtools/constitution/.github/workflows/validate.yml@v1.36.0
@@ -43,7 +43,11 @@ jobs:
43
43
  type=ref,event=pr
44
44
  type=semver,pattern={{version}}
45
45
  type=semver,pattern={{major}}.{{minor}}
46
- type=raw,value=latest,enable={{is_default_branch}}
46
+ # `latest` is the newest STABLE RELEASE, not the newest build of the
47
+ # branch, which has its own tag above (constitution 1.33.0, RT#1559).
48
+ # A prerelease is not what `latest` means, so it is excluded by the
49
+ # hyphen: SemVer puts every prerelease identifier after one.
50
+ type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && !contains(github.ref_name, '-') }}
47
51
 
48
52
  - name: Build and push
49
53
  uses: docker/build-push-action@v7
@@ -61,7 +65,7 @@ jobs:
61
65
  if: github.event_name != 'pull_request'
62
66
  uses: aquasecurity/trivy-action@v0.36.0
63
67
  with:
64
- image-ref: ${{ env.QUAY_IMAGE }}:latest
68
+ image-ref: ${{ env.QUAY_IMAGE }}:${{ steps.meta.outputs.version }}
65
69
  format: "table"
66
70
  exit-code: "0"
67
71
  severity: "CRITICAL,HIGH"
@@ -99,7 +103,11 @@ jobs:
99
103
  type=ref,event=pr
100
104
  type=semver,pattern={{version}}
101
105
  type=semver,pattern={{major}}.{{minor}}
102
- type=raw,value=latest,enable={{is_default_branch}}
106
+ # `latest` is the newest STABLE RELEASE, not the newest build of the
107
+ # branch, which has its own tag above (constitution 1.33.0, RT#1559).
108
+ # A prerelease is not what `latest` means, so it is excluded by the
109
+ # hyphen: SemVer puts every prerelease identifier after one.
110
+ type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && !contains(github.ref_name, '-') }}
103
111
 
104
112
  - name: Build and push
105
113
  uses: docker/build-push-action@v7
@@ -15,4 +15,4 @@ permissions:
15
15
  jobs:
16
16
  automerge:
17
17
  name: Dependabot
18
- uses: crunchtools/constitution/.github/workflows/dependabot-automerge.yml@v1.28.0
18
+ uses: crunchtools/constitution/.github/workflows/dependabot-automerge.yml@v1.31.0
@@ -22,7 +22,7 @@ jobs:
22
22
  permissions:
23
23
  contents: read
24
24
  issues: write
25
- uses: crunchtools/constitution/.github/workflows/dependency-audit.yml@v1.28.0
25
+ uses: crunchtools/constitution/.github/workflows/dependency-audit.yml@v1.31.0
26
26
 
27
27
  container-scan:
28
28
  name: Container Security Scan
@@ -0,0 +1,18 @@
1
+ # Every check runs from python-sdlc, pinned here once (constitution XII,
2
+ # v1.30.0). This `rev:` fixes the launcher, the image and every tool inside it,
3
+ # for this laptop and for CI, which reads the same line.
4
+ #
5
+ # What each hook runs is configured in pyproject.toml: [tool.sdlc.commit],
6
+ # [tool.sdlc.push], [tool.sdlc.ci]. `sdlc tiers` prints it.
7
+ #
8
+ # `default_install_hook_types` must name both: without pre-push, `pre-commit
9
+ # install` wires only the commit hook and every verdict silently stops running.
10
+
11
+ default_install_hook_types: [pre-commit, pre-push]
12
+
13
+ repos:
14
+ - repo: https://github.com/crunchtools/python-sdlc
15
+ rev: v0.6.1
16
+ hooks:
17
+ - id: sdlc-commit
18
+ - id: sdlc-push
@@ -2,9 +2,9 @@
2
2
 
3
3
  > **Version:** 1.3.0
4
4
  > **Ratified:** 2026-06-14
5
- > **Amended:** 2026-10-14
5
+ > **Amended:** 2026-11-09
6
6
  > **Status:** Active
7
- > **Inherits:** [crunchtools/constitution](https://github.com/crunchtools/constitution) v1.28.0
7
+ > **Inherits:** [crunchtools/constitution](https://github.com/crunchtools/constitution) v1.36.0
8
8
  > **Profile:** MCP Server
9
9
 
10
10
  This file holds what is specific to mcp-github. The fleet rules and the MCP
@@ -35,7 +35,8 @@ restated here.
35
35
  truncates long identifiers.
36
36
  - **API:** `Authorization: Bearer` header, never the URL; the
37
37
  `X-GitHub-Api-Version` header is pinned to `2022-11-28`; path parameters
38
- are URL-encoded; requests time out after 30s; responses are capped at 10MB.
38
+ are URL-encoded; requests time out after 30s; responses are capped at 10MB
39
+ while reading, not from the `content-length` header GitHub need not send.
39
40
  - **Redirects:** the API client follows none. A job log, which GitHub serves
40
41
  as a redirect to a pre-signed storage URL, is fetched by a separate client
41
42
  that has no token, over HTTPS to a named host only, streamed, with only its
@@ -6,6 +6,50 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.0] - 2026-11-09
10
+
11
+ ### Fixed
12
+
13
+ - **The JSON path had no ceiling.** The response size limit read the
14
+ `content-length` header and nothing else, so a reply without that header was
15
+ read whole into memory. The job-log download has always read in bounded
16
+ pieces, which is what made the gap easy to miss: the repo looked like it
17
+ streamed because one of its two read paths did. The JSON path now reads in
18
+ bounded pieces too, measured before each chunk is copied in.
19
+
20
+ - **An error that is not GitHub's JSON shape says what it was.** It arrived as
21
+ `Unknown error` or 200 bare characters of body with no indication of where
22
+ they came from, so an Enterprise login page, a proxy error and the API
23
+ refusing something were one message. Such a body is now quoted, bounded,
24
+ alongside the method, URL, status and content-type of the request it
25
+ answered. GitHub's own `message` is still used verbatim when present, now
26
+ length-bounded.
27
+
28
+ - **The URL in an error carries no secret.** Userinfo is dropped, and
29
+ `access_token` — GitHub's own retired query-string auth — along with
30
+ `client_secret`, `code` and `token` are redacted by name. By name rather
31
+ than by dropping the query string, because `per_page`, `state` and `sha` are
32
+ the diagnosis.
33
+
34
+ ### Added
35
+
36
+ - **The client says what it is.** Requests carry
37
+ `User-Agent: mcp-github-crunchtools/<version> (+<repo url>)`, with the
38
+ version read from installed package metadata. GitHub asks API clients to
39
+ send one, and httpx's default `python-httpx/<version>` is what bot
40
+ protection answers with a challenge page (RT #1596).
41
+
42
+ ### Changed
43
+
44
+ - Client tests drive `build_request` and `send` rather than `request`, because
45
+ the client now streams. The contract tests in
46
+ `tests/test_http_contract.py` use the existing `_patch_transport`, which
47
+ gives them a real `httpx.AsyncClient`: the agent string and the bounded read
48
+ are the client's own behaviour, so a mock standing in for it cannot see them.
49
+
50
+ - Inherits constitution v1.36.0, whose MCP Server profile 1.8.0 states the
51
+ outbound HTTP contract (RT #1601).
52
+
9
53
  ## [2.0.1] - 2026-10-31
10
54
 
11
55
  ### Security
@@ -27,7 +27,7 @@ FROM quay.io/hummingbird/python:3.14
27
27
 
28
28
  # Labels for container metadata
29
29
  LABEL name="mcp-github-crunchtools" \
30
- version="2.0.1" \
30
+ version="2.1.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: 2.0.1
3
+ Version: 2.1.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
@@ -35,3 +35,14 @@ check = "EV002-manifest_lint_suppression"
35
35
  path = "pyproject.toml"
36
36
  justification = "Each remaining global ruff ignore was verified against this codebase with the project's own pinned toolchain (`uv run ruff check --select <RULE>`, ignore list temporarily cleared for an unmasked read) before being kept: TRY003 (7 src/ files, GitHub API error messages), PLR0913/PLR0917 (4 src/ files -- server.py, tools/actions.py, tools/issues.py, tools/pull_requests.py -- MCP tool signatures mirror the wire API and genuinely take many named/positional parameters), PLR2004 (client.py plus tests, HTTP status/size comparisons), PLW0603 (client.py + config.py, module-level lazy singletons), TID252 (5 src/ files, this package's own internal relative imports). Two previously-global entries with zero hits anywhere (S106) or fixable outright (PIE790: one redundant `pass` removed from errors.py; RUF022: one unsorted `__all__` in tools/__init__.py) were resolved with `ruff check --fix` instead of kept ignored. S101 (assert), S105 (a literal secret value in test fixtures), and PLC0415 (importing the module/fixture under test inside the test body) were moved from the global ignore into `[tool.ruff.lint.per-file-ignores]` for tests/* -- all three fire only in tests/, never in src/."
37
37
  classification = "by_design"
38
+
39
+ # Ruff is not missing; it moved. Gourmand's IF001 looks for a literal
40
+ # `ruff-check` hook id, and under python-sdlc (constitution XII, v1.30.0) ruff
41
+ # runs inside the `sdlc-commit` hook, configured by [tool.ruff] in
42
+ # pyproject.toml. Same precedent as trentina.
43
+ [[exceptions]]
44
+ check = "linter_configuration"
45
+ path = ".pre-commit-config.yaml"
46
+ pattern = "ruff-check"
47
+ justification = "ruff runs inside the sdlc-commit hook from python-sdlc (constitution XII v1.30.0), configured by [tool.ruff] in pyproject.toml; Gourmand looks for a literal ruff hook id"
48
+ classification = "by_design"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-github-crunchtools"
3
- version = "2.0.1"
3
+ version = "2.1.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"
@@ -68,6 +68,7 @@ max-complexity = 10
68
68
  "tests/*" = ["S101", "S105", "PLC0415", "PLR2004"]
69
69
 
70
70
  [tool.mypy]
71
+ files = ["src"]
71
72
  python_version = "3.10"
72
73
  strict = true
73
74
  warn_return_any = true
@@ -76,3 +77,13 @@ warn_unused_configs = true
76
77
  [tool.pytest.ini_options]
77
78
  asyncio_mode = "auto"
78
79
  testpaths = ["tests"]
80
+
81
+ [tool.sdlc.push]
82
+ # No in-repo Semgrep rules: the check exits 2 when .semgrep is absent
83
+ # rather than scanning nothing and calling it a pass.
84
+ skip = ["semgrep"]
85
+
86
+ # The values the old CI job set, so the tests that read them still run.
87
+ [tool.sdlc.push.pytest]
88
+ env = { GITHUB_TOKEN = "test_token_for_ci" }
89
+
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/crunchtools/mcp-github",
8
8
  "source": "github"
9
9
  },
10
- "version": "2.0.1",
10
+ "version": "2.1.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "mcp-github-crunchtools",
15
- "version": "2.0.1",
15
+ "version": "2.1.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__ = "2.0.1"
32
+ __version__ = "2.1.0"
33
33
  __all__ = ["main", "mcp"]
34
34
 
35
35
 
@@ -5,9 +5,11 @@ All requests go through this client to ensure consistent security practices.
5
5
  """
6
6
 
7
7
  import ipaddress
8
+ import json
8
9
  import logging
9
10
  import re
10
11
  import time
12
+ from importlib.metadata import version
11
13
  from typing import Any
12
14
  from urllib.parse import urlparse
13
15
 
@@ -44,6 +46,62 @@ def _retry_after_seconds(headers: httpx.Headers) -> int | None:
44
46
 
45
47
 
46
48
  MAX_RESPONSE_SIZE = 10 * 1024 * 1024
49
+ BODY_SNIPPET_CHARS = 200
50
+ ELLIPSIS = "..."
51
+
52
+
53
+ def _body_snippet(body: bytes) -> str:
54
+ """A short, single-line excerpt of a response body, for an error message."""
55
+ prefix = body[: BODY_SNIPPET_CHARS * 4]
56
+ collapsed = " ".join(prefix.decode("utf-8", errors="replace").split())
57
+ if not collapsed:
58
+ return "<empty body>"
59
+ if len(collapsed) > BODY_SNIPPET_CHARS or len(body) > len(prefix):
60
+ return collapsed[: BODY_SNIPPET_CHARS - len(ELLIPSIS)] + ELLIPSIS
61
+ return collapsed
62
+
63
+
64
+ # Query parameters whose value is a secret rather than part of the request.
65
+ # `access_token` is GitHub's own retired query-string auth, and an Enterprise
66
+ # host in front of a proxy can take any of these.
67
+ REDACTED_PARAMS = frozenset({"access_token", "client_secret", "code", "token"})
68
+
69
+
70
+ def _safe_url(url: httpx.URL) -> httpx.URL:
71
+ """A URL fit to appear in an error message (Layer 1).
72
+
73
+ Userinfo first: it carries its secret before the path, where redacting
74
+ query parameters never reaches it. The rest is redacted by name, because
75
+ `per_page`, `state` and `sha` are the diagnosis.
76
+ """
77
+ url = url.copy_with(userinfo=b"")
78
+ for name in REDACTED_PARAMS & set(url.params):
79
+ url = url.copy_set_param(name, "***")
80
+ return url
81
+
82
+
83
+ def _where(response: httpx.Response) -> str:
84
+ """Which request a response answered, for an error message.
85
+
86
+ The requested URL rather than the path: `GITHUB_API_BASE_URL` points this
87
+ client at github.com or at an Enterprise host, and a base URL that answers
88
+ with a login page is not something a path alone can describe.
89
+ """
90
+ return (
91
+ f"{response.request.method} {_safe_url(response.request.url)} "
92
+ f"(HTTP {response.status_code}, {response.headers.get('content-type', 'no content-type')})"
93
+ )
94
+
95
+
96
+ # Identify this client by name. httpx otherwise sends `python-httpx/<version>`,
97
+ # which bot protection answers with an HTML challenge page instead of the API --
98
+ # the failure that cost an afternoon on mcp-wordpress (RT #1596). GitHub also
99
+ # asks API clients to send one. The version comes from installed package
100
+ # metadata so it cannot drift from pyproject.toml.
101
+ USER_AGENT = (
102
+ f"mcp-github-crunchtools/{version('mcp-github-crunchtools')} "
103
+ "(+https://github.com/crunchtools/mcp-github)"
104
+ )
47
105
  # A job log is streamed and only its tail is kept, so it may be larger than a
48
106
  # buffered API response; past this the download is abandoned.
49
107
  MAX_DOWNLOAD_SIZE = 2 * MAX_RESPONSE_SIZE
@@ -127,6 +185,7 @@ class GitHubClient:
127
185
  headers = {
128
186
  "Accept": DEFAULT_ACCEPT,
129
187
  "X-GitHub-Api-Version": GITHUB_API_VERSION,
188
+ "User-Agent": USER_AGENT,
130
189
  }
131
190
  if self._config.token:
132
191
  headers["Authorization"] = f"Bearer {self._config.token}"
@@ -177,11 +236,17 @@ class GitHubClient:
177
236
  params: dict[str, Any] | None = None,
178
237
  json_data: dict[str, Any] | None = None,
179
238
  accept: str | None = None,
180
- ) -> httpx.Response:
181
- """Send one authenticated API request and return the raw response.
239
+ ) -> tuple[httpx.Response, bytes]:
240
+ """Send one authenticated API request and return it with its bounded body.
182
241
 
183
242
  Transport failures and oversized responses are raised as
184
243
  GitHubApiError; the HTTP status is left for the caller to judge.
244
+
245
+ Streamed rather than read whole, because the cap has to hold while the
246
+ body arrives. The `content-length` check this replaces was the only
247
+ limit on the JSON path, and GitHub does not always send that header --
248
+ the job-log download below has always read in bounded pieces, which is
249
+ what made the gap easy to miss.
185
250
  """
186
251
  # Resolved before anything else, so a call with no usable token makes
187
252
  # no request at all.
@@ -194,23 +259,49 @@ class GitHubClient:
194
259
  logger.debug("API request: %s %s", method, path)
195
260
 
196
261
  try:
197
- response = await client.request(
262
+ request = client.build_request(
198
263
  method=method,
199
264
  url=path,
200
265
  params=params,
201
266
  json=json_data,
202
267
  headers=headers or None,
203
268
  )
269
+ response = await client.send(request, stream=True)
270
+ try:
271
+ body = await self._read_capped(response)
272
+ finally:
273
+ await response.aclose()
204
274
  except httpx.TimeoutException as e:
205
275
  raise GitHubApiError(0, f"Request timeout: {e}") from e
206
276
  except httpx.RequestError as e:
207
277
  raise GitHubApiError(0, f"Request failed: {e}") from e
208
278
 
209
- content_length = response.headers.get("content-length")
210
- if content_length and int(content_length) > MAX_RESPONSE_SIZE:
211
- raise GitHubApiError(0, "Response too large")
279
+ return response, body
280
+
281
+ @staticmethod
282
+ async def _read_capped(response: httpx.Response) -> bytes:
283
+ """Read the body, aborting as soon as it would pass MAX_RESPONSE_SIZE.
212
284
 
213
- return response
285
+ One buffer, measured before the copy: `aiter_bytes()` promises nothing
286
+ about chunk size -- one chunk can be the whole body -- so appending
287
+ first would copy in exactly the response being refused.
288
+ """
289
+ declared = response.headers.get("content-length")
290
+ if declared and declared.isdigit() and int(declared) > MAX_RESPONSE_SIZE:
291
+ raise GitHubApiError(
292
+ response.status_code,
293
+ f"{_where(response)} declared {declared} bytes, over the "
294
+ f"{MAX_RESPONSE_SIZE} byte limit",
295
+ )
296
+ body = bytearray()
297
+ async for chunk in response.aiter_bytes():
298
+ if len(body) + len(chunk) > MAX_RESPONSE_SIZE:
299
+ raise GitHubApiError(
300
+ response.status_code,
301
+ f"{_where(response)} sent over the {MAX_RESPONSE_SIZE} byte limit",
302
+ )
303
+ body += chunk
304
+ return bytes(body)
214
305
 
215
306
  async def _request(
216
307
  self,
@@ -242,22 +333,25 @@ class GitHubClient:
242
333
  PermissionDeniedError: On 401 authorization failures
243
334
  NotFoundError: When the resource does not exist
244
335
  """
245
- response = await self._send(method, path, params, json_data, accept)
336
+ response, body = await self._send(method, path, params, json_data, accept)
246
337
 
247
338
  if not response.is_success:
248
- self._handle_error_response(response)
339
+ self._handle_error_response(response, body)
249
340
 
250
341
  if response.status_code == 204:
251
342
  return {"status": "deleted"}
252
343
 
253
344
  content_type = response.headers.get("content-type", "")
254
345
  if "application/json" not in content_type:
255
- return {"content": response.text}
346
+ return {"content": body.decode("utf-8", errors="replace")}
256
347
 
257
348
  try:
258
- parsed = response.json()
349
+ parsed = json.loads(body)
259
350
  except ValueError as e:
260
- raise GitHubApiError(response.status_code, f"Invalid JSON response: {e}") from e
351
+ raise GitHubApiError(
352
+ response.status_code,
353
+ f"{_where(response)} is not JSON ({e}): {_body_snippet(body)}",
354
+ ) from e
261
355
 
262
356
  if isinstance(parsed, list):
263
357
  return self._wrap_list_response(parsed, response)
@@ -291,7 +385,7 @@ class GitHubClient:
291
385
 
292
386
  return wrapped
293
387
 
294
- def _handle_error_response(self, response: httpx.Response) -> None:
388
+ def _handle_error_response(self, response: httpx.Response, body: bytes) -> None:
295
389
  """Handle error responses from the API.
296
390
 
297
391
  GitHub signals primary rate limiting with HTTP 403 (not 429) plus
@@ -299,23 +393,28 @@ class GitHubClient:
299
393
 
300
394
  Args:
301
395
  response: HTTP response
396
+ body: its bounded body, already read
302
397
 
303
398
  Raises:
304
399
  Various UserError subclasses based on error type
305
400
  """
306
401
  status_code = response.status_code
307
402
 
308
- error_msg: str = "Unknown error"
403
+ error_msg: str = ""
309
404
  try:
310
- error_body = response.json()
405
+ error_body = json.loads(body)
311
406
  if isinstance(error_body, dict):
312
407
  raw_msg = error_body.get("message", error_body.get("error"))
313
408
  if isinstance(raw_msg, (dict, str, int, float)):
314
- error_msg = str(raw_msg)
409
+ error_msg = str(raw_msg)[: BODY_SNIPPET_CHARS * 4]
315
410
  else:
316
- error_msg = str(error_body)
411
+ error_msg = str(error_body)[: BODY_SNIPPET_CHARS * 4]
317
412
  except ValueError:
318
- error_msg = response.text[:200] if response.text else "Unknown error"
413
+ error_msg = ""
414
+ # A body that is not GitHub's JSON error shape is quoted with the
415
+ # request it answered: that is what tells an Enterprise login page or a
416
+ # proxy apart from the API refusing something.
417
+ error_msg = error_msg or f"{_where(response)} returned {_body_snippet(body)}"
319
418
 
320
419
  rate_remaining = response.headers.get("x-ratelimit-remaining")
321
420
 
@@ -387,10 +486,10 @@ class GitHubClient:
387
486
  RateLimitError, PermissionDeniedError, NotFoundError: As for any
388
487
  API request, from the first hop
389
488
  """
390
- response = await self._send("GET", path)
489
+ response, body = await self._send("GET", path)
391
490
  if not response.is_redirect:
392
491
  if not response.is_success:
393
- self._handle_error_response(response)
492
+ self._handle_error_response(response, body)
394
493
  raise GitHubApiError(response.status_code, "Expected a redirect to a download")
395
494
 
396
495
  location = response.headers.get("location", "")
@@ -46,7 +46,7 @@ READ_ONLY = {"readOnlyHint": True}
46
46
 
47
47
  mcp = FastMCP(
48
48
  name="mcp-github",
49
- version="2.0.1",
49
+ version="2.1.0",
50
50
  instructions=(
51
51
  "Secure MCP server for GitHub repositories: issues, pull requests "
52
52
  "(open, review discussion, diffs, CI checks, merge), Actions runs and "
@@ -51,9 +51,7 @@ async def get_file_content(
51
51
 
52
52
  client = get_client()
53
53
  params = {"ref": ref} if ref else None
54
- result = await client.get(
55
- f"/repos/{owner}/{repo}/contents/{encoded_path}", params=params
56
- )
54
+ result = await client.get(f"/repos/{owner}/{repo}/contents/{encoded_path}", params=params)
57
55
 
58
56
  if isinstance(result.get("content"), list):
59
57
  raise ValidationError("path refers to a directory, not a file")
@@ -119,6 +117,4 @@ async def list_repo_tree(
119
117
 
120
118
  client = get_client()
121
119
  params = {"recursive": "1"} if recursive else None
122
- return await client.get(
123
- f"/repos/{owner}/{repo}/git/trees/{encoded_sha}", params=params
124
- )
120
+ return await client.get(f"/repos/{owner}/{repo}/git/trees/{encoded_sha}", params=params)
@@ -2,7 +2,8 @@
2
2
 
3
3
  import os
4
4
  from collections.abc import Callable, Generator
5
- from unittest.mock import AsyncMock, patch
5
+ from typing import Any
6
+ from unittest.mock import AsyncMock, MagicMock, patch
6
7
 
7
8
  import httpx
8
9
  import pytest
@@ -41,46 +42,67 @@ def _mock_response(
41
42
  )
42
43
 
43
44
 
44
- def _patch_client(mock_response: httpx.Response):
45
- """Patch the httpx AsyncClient to return a mock response.
45
+ def _answer(template: httpx.Response, request: httpx.Request) -> httpx.Response:
46
+ """A fresh response with a template's status, type and body, for one request.
47
+
48
+ Fresh because a streamed body can be read once, and because the error
49
+ messages under test name `response.request.url` -- the URL the client
50
+ actually built, not the placeholder a template carries. Content-length is
51
+ dropped so httpx recomputes it, unless a test set one deliberately.
52
+ """
53
+ keep = template.headers.get("content-length") if "content-length" in template.headers else None
54
+ headers = {k: v for k, v in template.headers.items() if k.lower() != "content-length"}
55
+ response = httpx.Response(
56
+ template.status_code, headers=headers, content=template.content, request=request
57
+ )
58
+ if keep is not None:
59
+ response.headers["content-length"] = keep
60
+ return response
61
+
46
62
 
47
- Sets GITHUB_TOKEN so config initializes, then mocks the HTTP layer.
63
+ def _mock_http(**send: Any) -> AsyncMock:
64
+ """A stand-in for the client, whose `build_request` builds a real request.
65
+
66
+ The client streams now: `build_request` then `send(..., stream=True)`. A
67
+ real `httpx.Client` builds the request, so assertions on the method, url,
68
+ params, json and headers a caller passed still mean what they did, while
69
+ `send` is what each test scripts.
70
+
71
+ What this cannot see is the client's own behaviour -- the User-Agent it
72
+ adds and the bounded read it performs. Those are tested through
73
+ `_patch_transport`, which uses a real client (constitution v1.36.0,
74
+ Layer 3).
48
75
  """
76
+ builder = httpx.Client(base_url="https://api.github.com")
77
+ mock_http = AsyncMock(spec=httpx.AsyncClient)
78
+ mock_http.build_request = MagicMock(side_effect=builder.build_request)
79
+ mock_http.send = AsyncMock(**send)
80
+ return mock_http
81
+
82
+
83
+ def _patch_client(mock_response: httpx.Response):
84
+ """Patch the AsyncClient so every request is answered with one response."""
49
85
  import mcp_github_crunchtools.client as client_mod
50
86
  import mcp_github_crunchtools.config as config_mod
51
87
 
52
88
  client_mod._client = None
53
89
  config_mod._config = None
54
-
55
90
  os.environ.setdefault("GITHUB_TOKEN", "ghp_test_mock_token")
56
91
 
57
- mock_http = AsyncMock(spec=httpx.AsyncClient)
58
- mock_http.request = AsyncMock(return_value=mock_response)
59
-
60
- return patch.object(
61
- httpx,
62
- "AsyncClient",
63
- return_value=mock_http,
64
- )
92
+ return patch.object(httpx, "AsyncClient", return_value=_mock_http(return_value=mock_response))
65
93
 
66
94
 
67
95
  def _patch_client_sequence(*mock_responses: httpx.Response):
68
- """Patch the AsyncClient to return a sequence of responses."""
96
+ """Patch the AsyncClient to answer with a sequence of responses."""
69
97
  import mcp_github_crunchtools.client as client_mod
70
98
  import mcp_github_crunchtools.config as config_mod
71
99
 
72
100
  client_mod._client = None
73
101
  config_mod._config = None
74
-
75
102
  os.environ.setdefault("GITHUB_TOKEN", "ghp_test_mock_token")
76
103
 
77
- mock_http = AsyncMock(spec=httpx.AsyncClient)
78
- mock_http.request = AsyncMock(side_effect=list(mock_responses))
79
-
80
104
  return patch.object(
81
- httpx,
82
- "AsyncClient",
83
- return_value=mock_http,
105
+ httpx, "AsyncClient", return_value=_mock_http(side_effect=list(mock_responses))
84
106
  )
85
107
 
86
108