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.
- mcp_github_crunchtools-2.1.0/.github/workflows/ci.yml +46 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/constitution.yml +1 -1
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/container.yml +11 -3
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/dependabot-automerge.yml +1 -1
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/security.yml +1 -1
- mcp_github_crunchtools-2.1.0/.pre-commit-config.yaml +18 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/memory/constitution.md +4 -3
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/CHANGELOG.md +44 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/Containerfile +1 -1
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/PKG-INFO +1 -1
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/gourmand-exceptions.toml +11 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/pyproject.toml +12 -1
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/server.json +2 -2
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/__init__.py +1 -1
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/client.py +119 -20
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/server.py +1 -1
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/files.py +2 -6
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/conftest.py +43 -21
- mcp_github_crunchtools-2.1.0/tests/test_http_contract.py +150 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/test_tools.py +33 -33
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/uv.lock +58 -58
- mcp_github_crunchtools-2.0.1/.github/workflows/ci.yml +0 -57
- mcp_github_crunchtools-2.0.1/.pre-commit-config.yaml +0 -39
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.gatehouse-ignore +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/dependabot.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/ashigaru.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/gatehouse-retriage.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/gatehouse.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/publish.yml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.gitignore +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/specs/000-baseline/spec.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/specs/001-pr-merge-logs-release/spec.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/specs/002-request-token-merge-gate/spec.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/templates/plan-template.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/templates/spec-template.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/CLAUDE.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/LICENSE +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/README.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/SECURITY.md +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/gourmand.toml +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/__main__.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/config.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/errors.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/models.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/__init__.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/actions.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/issues.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/pull_requests.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/releases.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/tools/search.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/__init__.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/test_merge_gate.py +0 -0
- {mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/tests/test_request_token.py +0 -0
- {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
|
{mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/container.yml
RENAMED
|
@@ -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
|
-
|
|
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 }}
|
|
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
|
-
|
|
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
|
{mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.github/workflows/security.yml
RENAMED
|
@@ -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.
|
|
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
|
{mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/.specify/memory/constitution.md
RENAMED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> **Version:** 1.3.0
|
|
4
4
|
> **Ratified:** 2026-06-14
|
|
5
|
-
> **Amended:** 2026-
|
|
5
|
+
> **Amended:** 2026-11-09
|
|
6
6
|
> **Status:** Active
|
|
7
|
-
> **Inherits:** [crunchtools/constitution](https://github.com/crunchtools/constitution) v1.
|
|
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
|
|
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" \
|
|
@@ -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
|
|
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
|
|
10
|
+
"version": "2.1.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "mcp-github-crunchtools",
|
|
15
|
-
"version": "2.0
|
|
15
|
+
"version": "2.1.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
{mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/client.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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":
|
|
346
|
+
return {"content": body.decode("utf-8", errors="replace")}
|
|
256
347
|
|
|
257
348
|
try:
|
|
258
|
-
parsed =
|
|
349
|
+
parsed = json.loads(body)
|
|
259
350
|
except ValueError as e:
|
|
260
|
-
raise GitHubApiError(
|
|
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 = "
|
|
403
|
+
error_msg: str = ""
|
|
309
404
|
try:
|
|
310
|
-
error_body =
|
|
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 =
|
|
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", "")
|
{mcp_github_crunchtools-2.0.1 → mcp_github_crunchtools-2.1.0}/src/mcp_github_crunchtools/server.py
RENAMED
|
@@ -46,7 +46,7 @@ READ_ONLY = {"readOnlyHint": True}
|
|
|
46
46
|
|
|
47
47
|
mcp = FastMCP(
|
|
48
48
|
name="mcp-github",
|
|
49
|
-
version="2.0
|
|
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
|
|
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
|
|
45
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|