jaimenbell-github-mcp 0.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.
- jaimenbell_github_mcp-0.1.0/LICENSE +21 -0
- jaimenbell_github_mcp-0.1.0/PKG-INFO +187 -0
- jaimenbell_github_mcp-0.1.0/README.md +173 -0
- jaimenbell_github_mcp-0.1.0/github_mcp/__init__.py +0 -0
- jaimenbell_github_mcp-0.1.0/github_mcp/client.py +155 -0
- jaimenbell_github_mcp-0.1.0/github_mcp/config.py +127 -0
- jaimenbell_github_mcp-0.1.0/github_mcp/groups/__init__.py +0 -0
- jaimenbell_github_mcp-0.1.0/github_mcp/groups/read.py +259 -0
- jaimenbell_github_mcp-0.1.0/github_mcp/groups/write.py +89 -0
- jaimenbell_github_mcp-0.1.0/github_mcp/server.py +104 -0
- jaimenbell_github_mcp-0.1.0/jaimenbell_github_mcp.egg-info/PKG-INFO +187 -0
- jaimenbell_github_mcp-0.1.0/jaimenbell_github_mcp.egg-info/SOURCES.txt +21 -0
- jaimenbell_github_mcp-0.1.0/jaimenbell_github_mcp.egg-info/dependency_links.txt +1 -0
- jaimenbell_github_mcp-0.1.0/jaimenbell_github_mcp.egg-info/requires.txt +6 -0
- jaimenbell_github_mcp-0.1.0/jaimenbell_github_mcp.egg-info/top_level.txt +1 -0
- jaimenbell_github_mcp-0.1.0/pyproject.toml +27 -0
- jaimenbell_github_mcp-0.1.0/setup.cfg +4 -0
- jaimenbell_github_mcp-0.1.0/tests/test_client.py +182 -0
- jaimenbell_github_mcp-0.1.0/tests/test_config.py +95 -0
- jaimenbell_github_mcp-0.1.0/tests/test_live_smoke.py +26 -0
- jaimenbell_github_mcp-0.1.0/tests/test_read.py +249 -0
- jaimenbell_github_mcp-0.1.0/tests/test_server.py +46 -0
- jaimenbell_github_mcp-0.1.0/tests/test_write.py +146 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaimen Bell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jaimenbell-github-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public read+write reference MCP server over the GitHub REST API. Env-gated tool groups (read always on, write off by default), fine-grained PAT auth that degrades to the unauthenticated tier, typed rate-limit/error payloads. Reference portfolio implementation, NOT the official GitHub MCP server.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: fastmcp==3.4.2
|
|
9
|
+
Requires-Dist: httpx==0.28.1
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest==9.0.3; extra == "test"
|
|
12
|
+
Requires-Dist: respx==0.23.1; extra == "test"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# github-mcp
|
|
16
|
+
|
|
17
|
+
A public read+write MCP server over the GitHub REST API -- built to the
|
|
18
|
+
[desktop-mcp](https://github.com)/rag-mcp/mcp-factory standard (own
|
|
19
|
+
pyproject, fastmcp server, honest README, real test suite). Env-gated tool
|
|
20
|
+
groups, **write disabled by default**.
|
|
21
|
+
|
|
22
|
+
## What this is / is not
|
|
23
|
+
|
|
24
|
+
This is a **reference portfolio implementation** demonstrating a hardened
|
|
25
|
+
read+write MCP server pattern over a real external SaaS API (GitHub) --
|
|
26
|
+
env-gated tool groups, typed error/rate-limit handling, auth that degrades
|
|
27
|
+
gracefully, a real test suite. It exists to show, concretely, "I build
|
|
28
|
+
read/write MCP servers over external APIs" with a link a client can click.
|
|
29
|
+
|
|
30
|
+
**It is NOT the official GitHub MCP server.** It does not aim for parity
|
|
31
|
+
with GitHub's own MCP offering (GraphQL, Actions, webhooks, GitHub Apps are
|
|
32
|
+
all out of scope -- see below). It started life as a factory-scaffolded
|
|
33
|
+
read-only demo ([mcp-factory](https://github.com)'s
|
|
34
|
+
`generated/github_read_server.py`) and was hand-hardened into this
|
|
35
|
+
standalone read+write server -- the scaffold-then-harden path is itself part
|
|
36
|
+
of the story this repo tells.
|
|
37
|
+
|
|
38
|
+
## Tool groups
|
|
39
|
+
|
|
40
|
+
| Group | Tools | Default state |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `read` | `get_repo`, `list_issues`, `get_issue`, `list_pull_requests`, `get_pull_request`, `get_file_content`, `search_repos`, `get_user`, `list_commits` | always on, works unauthenticated (GitHub's 60 req/hr tier) |
|
|
43
|
+
| `write` | `create_issue`, `comment_on_issue`, `update_issue_state`, `add_labels`, `create_pr_review_comment` | env-gated, **OFF by default** -- requires `GITHUB_MCP_ENABLE_WRITE=1` **and** `GITHUB_TOKEN` |
|
|
44
|
+
|
|
45
|
+
A disabled write call returns a structured `policy_refusal` error (never a
|
|
46
|
+
silent no-op, never a crash). A write call with the group enabled but no
|
|
47
|
+
token returns a structured `auth_required` error -- the group gate and the
|
|
48
|
+
token precondition are checked independently, both before any network call.
|
|
49
|
+
|
|
50
|
+
## Write-safety-off-by-default
|
|
51
|
+
|
|
52
|
+
This is defense-in-depth, mirroring desktop-mcp's `input` group: harness-level
|
|
53
|
+
permission prompts are the first gate, but the server itself refuses every
|
|
54
|
+
write tool unless its own environment explicitly opts in with
|
|
55
|
+
`GITHUB_MCP_ENABLE_WRITE=1`, and even then refuses without a `GITHUB_TOKEN`.
|
|
56
|
+
A misconfigured or overly-permissive MCP host cannot turn on GitHub mutations
|
|
57
|
+
this process wasn't deliberately configured to allow. The registration this
|
|
58
|
+
repo ships with (see `~/.claude.json`'s `github-mcp` entry) has the write
|
|
59
|
+
group **absent from env** -- enabling it is a deliberate per-registration
|
|
60
|
+
operator choice, not a code change.
|
|
61
|
+
|
|
62
|
+
## Honest-capabilities table
|
|
63
|
+
|
|
64
|
+
Every claim below maps to the file that implements it and the test(s) that
|
|
65
|
+
verify it -- no capability is asserted without a corresponding implementation
|
|
66
|
+
and test.
|
|
67
|
+
|
|
68
|
+
| Claim | Implementation | Verified by |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| Repo metadata (stars, language, license, default branch, archived flag...) | `github_mcp/groups/read.py::get_repo` | `tests/test_read.py::TestGetRepo`, live: `tests/test_live_smoke.py::test_live_get_repo_real_json` |
|
|
71
|
+
| List / fetch issues (PRs filtered from list) | `github_mcp/groups/read.py::list_issues`, `get_issue` | `tests/test_read.py::TestListIssues`, `TestGetIssue` |
|
|
72
|
+
| List / fetch pull requests | `github_mcp/groups/read.py::list_pull_requests`, `get_pull_request` | `tests/test_read.py::TestListPullRequests`, `TestGetPullRequest` |
|
|
73
|
+
| Read a repo file's content (base64-decoded, binary detected not decoded) | `github_mcp/groups/read.py::get_file_content` | `tests/test_read.py::TestGetFileContent` |
|
|
74
|
+
| Search public repositories | `github_mcp/groups/read.py::search_repos` | `tests/test_read.py::TestSearchRepos` |
|
|
75
|
+
| Public user/org profile | `github_mcp/groups/read.py::get_user` | `tests/test_read.py::TestGetUser` |
|
|
76
|
+
| List commits on a branch/ref | `github_mcp/groups/read.py::list_commits` | `tests/test_read.py::TestListCommits` |
|
|
77
|
+
| Open an issue | `github_mcp/groups/write.py::create_issue` | `tests/test_write.py::TestCreateIssue` |
|
|
78
|
+
| Comment on an issue/PR | `github_mcp/groups/write.py::comment_on_issue` | `tests/test_write.py::TestCommentOnIssue` |
|
|
79
|
+
| Open/close an issue | `github_mcp/groups/write.py::update_issue_state` | `tests/test_write.py::TestUpdateIssueState` |
|
|
80
|
+
| Add labels to an issue/PR | `github_mcp/groups/write.py::add_labels` | `tests/test_write.py::TestAddLabels` |
|
|
81
|
+
| Create a PR review comment on a diff line | `github_mcp/groups/write.py::create_pr_review_comment` | `tests/test_write.py::TestCreatePrReviewComment` |
|
|
82
|
+
| Write group OFF by default, structured refusal when disabled | `github_mcp/config.py::group_enabled`, `gated_write` | `tests/test_config.py::TestGroupEnabled`, `tests/test_write.py::TestGateDisabledByDefault` |
|
|
83
|
+
| Write tools require a token even when the group is enabled | `github_mcp/config.py::check_write_preconditions` | `tests/test_config.py::TestCheckWritePreconditions`, `tests/test_write.py::TestAuthRequiredWhenGroupEnabled` |
|
|
84
|
+
| Fine-grained PAT auth, degrades to unauthenticated tier when absent | `github_mcp/client.py::_headers` | `tests/test_client.py::TestAuthHeaderInjection`, `tests/test_read.py::TestUnauthDegrade` |
|
|
85
|
+
| GitHub primary rate-limit (403 + `X-RateLimit-Reset`) and secondary rate-limit (403 + `Retry-After`, no `X-RateLimit-Remaining`) both surface as a typed error with reset/retry time, never a crash | `github_mcp/client.py::_rate_limit_error`, `_is_rate_limit_response` | `tests/test_client.py::TestRateLimitError`, `tests/test_client.py::TestRateLimitError::test_secondary_rate_limit_no_ratelimit_headers_retry_after_only`, `tests/test_read.py::TestUnauthDegrade::test_get_repo_rate_limited_without_token_is_typed` |
|
|
86
|
+
| Malformed owner/repo/path (control chars etc.) that would raise `httpx.InvalidURL` surfaces as a typed error, never an uncaught exception | `github_mcp/client.py::request` | `tests/test_client.py::TestNetworkError::test_malformed_path_raises_invalid_url_caught_as_network_error` |
|
|
87
|
+
| Generic 4xx/5xx surfaces as a typed error, never a crash | `github_mcp/client.py::_api_error` | `tests/test_client.py::TestApiError` |
|
|
88
|
+
| Non-JSON / malformed responses and network failures surface as typed errors | `github_mcp/client.py::_handle_response`, `request` | `tests/test_client.py::TestDecodeError`, `TestNetworkError` |
|
|
89
|
+
|
|
90
|
+
## Limitations (read before relying on this)
|
|
91
|
+
|
|
92
|
+
- **REST v1 only.** No GraphQL API coverage.
|
|
93
|
+
- **No webhooks / GitHub App auth.** Fine-grained PAT only.
|
|
94
|
+
- **No Actions/workflow-dispatch tools.** Issue/PR CRUD is the v1 write surface.
|
|
95
|
+
- **Unauthenticated read is rate-limited to 60 req/hr** by GitHub itself (10
|
|
96
|
+
req/min for search) -- expect `rate_limited` errors under sustained
|
|
97
|
+
unauthenticated use; set `GITHUB_TOKEN` (even a read-only fine-grained PAT)
|
|
98
|
+
to raise this considerably.
|
|
99
|
+
- **`get_file_content` truncates past 100KB** and reports (rather than
|
|
100
|
+
decodes) non-UTF-8 files.
|
|
101
|
+
- **No pagination beyond a single page** for list endpoints (`limit`, capped
|
|
102
|
+
per-endpoint, is the only page-size control in v1).
|
|
103
|
+
- **Not registered with the mcp-factory hub.** Ships as a standalone repo
|
|
104
|
+
(own pyproject, system Python312 install), matching the rag-mcp/desktop-mcp
|
|
105
|
+
model.
|
|
106
|
+
|
|
107
|
+
## Env vars
|
|
108
|
+
|
|
109
|
+
| Var | Effect | Default |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `GITHUB_MCP_ENABLE_WRITE` | enable the `write` tool group | unset (off) |
|
|
112
|
+
| `GITHUB_TOKEN` | fine-grained PAT; read works without it (degraded unauth rate), write requires it | unset |
|
|
113
|
+
| `GITHUB_MCP_LIVE` | `1` to run the real-network smoke test (see Testing) | unset (skip) |
|
|
114
|
+
|
|
115
|
+
## Usage examples
|
|
116
|
+
|
|
117
|
+
```jsonc
|
|
118
|
+
// A tool call from the MCP host, illustrative -- not a shell command.
|
|
119
|
+
{"tool": "get_repo", "arguments": {"owner": "anthropics", "repo": "anthropic-sdk-python"}}
|
|
120
|
+
// -> {"ok": true, "full_name": "anthropics/anthropic-sdk-python", "stargazers_count": 1234, ...}
|
|
121
|
+
|
|
122
|
+
// write group disabled (default):
|
|
123
|
+
{"tool": "create_issue", "arguments": {"owner": "o", "repo": "r", "title": "bug"}}
|
|
124
|
+
// -> {"ok": false, "error": {"type": "policy_refusal", "group": "write", "required_env": "GITHUB_MCP_ENABLE_WRITE", ...}}
|
|
125
|
+
|
|
126
|
+
// write group enabled, no token set:
|
|
127
|
+
{"tool": "create_issue", "arguments": {"owner": "o", "repo": "r", "title": "bug"}}
|
|
128
|
+
// -> {"ok": false, "error": {"type": "auth_required", "tool": "create_issue", ...}}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Testing
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
# unit suite (respx-mocked api.github.com, no real network touched)
|
|
135
|
+
python -m pytest -q
|
|
136
|
+
|
|
137
|
+
# handshake check -- prints every registered tool name
|
|
138
|
+
python scripts/list_tools.py
|
|
139
|
+
|
|
140
|
+
# real-network read smoke (get_repo against a stable public repo;
|
|
141
|
+
# no write smoke exists anywhere in this suite -- see safety rails above)
|
|
142
|
+
GITHUB_MCP_LIVE=1 python -m pytest -q -k live_get_repo
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Install
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
pip install -r requirements.txt # or: pip install .
|
|
149
|
+
# deps: fastmcp==3.4.2, httpx==0.28.1
|
|
150
|
+
# test-only: pytest==9.0.3, respx==0.23.1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Setup / connect
|
|
154
|
+
|
|
155
|
+
1. `pip install -r requirements.txt` on Python 3.12+.
|
|
156
|
+
2. (Optional) generate a [fine-grained PAT](https://github.com/settings/tokens?type=beta)
|
|
157
|
+
scoped to the repos you want read+write access to (Issues: read/write,
|
|
158
|
+
Pull requests: read/write, Contents: read is enough for v1). Read tools
|
|
159
|
+
work with **no token at all** -- they just run at GitHub's unauthenticated
|
|
160
|
+
60 req/hr tier.
|
|
161
|
+
3. Add to your MCP host config (e.g. `~/.claude.json`):
|
|
162
|
+
|
|
163
|
+
```jsonc
|
|
164
|
+
{
|
|
165
|
+
"mcpServers": {
|
|
166
|
+
"github-mcp": {
|
|
167
|
+
"command": "C:\\Users\\jaime\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
|
168
|
+
"args": ["C:\\Users\\jaime\\projects\\github-mcp\\run_server.py"],
|
|
169
|
+
"env": {
|
|
170
|
+
"GITHUB_TOKEN": "your-fine-grained-pat-here"
|
|
171
|
+
// GITHUB_MCP_ENABLE_WRITE intentionally absent -- write stays off
|
|
172
|
+
// until you deliberately opt in per-deployment.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
4. To enable write tools for a given deployment, add
|
|
180
|
+
`"GITHUB_MCP_ENABLE_WRITE": "1"` to that entry's `env` block. This is a
|
|
181
|
+
registration-time operator decision, not a code change.
|
|
182
|
+
|
|
183
|
+
Registered in `~/.claude.json` as `github-mcp` (stdio, system Python312,
|
|
184
|
+
`read` group always on, `write` group absent from env -- off).
|
|
185
|
+
|
|
186
|
+
<!-- MCP registry ownership marker -->
|
|
187
|
+
mcp-name: io.github.jaimenbell/github-mcp
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# github-mcp
|
|
2
|
+
|
|
3
|
+
A public read+write MCP server over the GitHub REST API -- built to the
|
|
4
|
+
[desktop-mcp](https://github.com)/rag-mcp/mcp-factory standard (own
|
|
5
|
+
pyproject, fastmcp server, honest README, real test suite). Env-gated tool
|
|
6
|
+
groups, **write disabled by default**.
|
|
7
|
+
|
|
8
|
+
## What this is / is not
|
|
9
|
+
|
|
10
|
+
This is a **reference portfolio implementation** demonstrating a hardened
|
|
11
|
+
read+write MCP server pattern over a real external SaaS API (GitHub) --
|
|
12
|
+
env-gated tool groups, typed error/rate-limit handling, auth that degrades
|
|
13
|
+
gracefully, a real test suite. It exists to show, concretely, "I build
|
|
14
|
+
read/write MCP servers over external APIs" with a link a client can click.
|
|
15
|
+
|
|
16
|
+
**It is NOT the official GitHub MCP server.** It does not aim for parity
|
|
17
|
+
with GitHub's own MCP offering (GraphQL, Actions, webhooks, GitHub Apps are
|
|
18
|
+
all out of scope -- see below). It started life as a factory-scaffolded
|
|
19
|
+
read-only demo ([mcp-factory](https://github.com)'s
|
|
20
|
+
`generated/github_read_server.py`) and was hand-hardened into this
|
|
21
|
+
standalone read+write server -- the scaffold-then-harden path is itself part
|
|
22
|
+
of the story this repo tells.
|
|
23
|
+
|
|
24
|
+
## Tool groups
|
|
25
|
+
|
|
26
|
+
| Group | Tools | Default state |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `read` | `get_repo`, `list_issues`, `get_issue`, `list_pull_requests`, `get_pull_request`, `get_file_content`, `search_repos`, `get_user`, `list_commits` | always on, works unauthenticated (GitHub's 60 req/hr tier) |
|
|
29
|
+
| `write` | `create_issue`, `comment_on_issue`, `update_issue_state`, `add_labels`, `create_pr_review_comment` | env-gated, **OFF by default** -- requires `GITHUB_MCP_ENABLE_WRITE=1` **and** `GITHUB_TOKEN` |
|
|
30
|
+
|
|
31
|
+
A disabled write call returns a structured `policy_refusal` error (never a
|
|
32
|
+
silent no-op, never a crash). A write call with the group enabled but no
|
|
33
|
+
token returns a structured `auth_required` error -- the group gate and the
|
|
34
|
+
token precondition are checked independently, both before any network call.
|
|
35
|
+
|
|
36
|
+
## Write-safety-off-by-default
|
|
37
|
+
|
|
38
|
+
This is defense-in-depth, mirroring desktop-mcp's `input` group: harness-level
|
|
39
|
+
permission prompts are the first gate, but the server itself refuses every
|
|
40
|
+
write tool unless its own environment explicitly opts in with
|
|
41
|
+
`GITHUB_MCP_ENABLE_WRITE=1`, and even then refuses without a `GITHUB_TOKEN`.
|
|
42
|
+
A misconfigured or overly-permissive MCP host cannot turn on GitHub mutations
|
|
43
|
+
this process wasn't deliberately configured to allow. The registration this
|
|
44
|
+
repo ships with (see `~/.claude.json`'s `github-mcp` entry) has the write
|
|
45
|
+
group **absent from env** -- enabling it is a deliberate per-registration
|
|
46
|
+
operator choice, not a code change.
|
|
47
|
+
|
|
48
|
+
## Honest-capabilities table
|
|
49
|
+
|
|
50
|
+
Every claim below maps to the file that implements it and the test(s) that
|
|
51
|
+
verify it -- no capability is asserted without a corresponding implementation
|
|
52
|
+
and test.
|
|
53
|
+
|
|
54
|
+
| Claim | Implementation | Verified by |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| Repo metadata (stars, language, license, default branch, archived flag...) | `github_mcp/groups/read.py::get_repo` | `tests/test_read.py::TestGetRepo`, live: `tests/test_live_smoke.py::test_live_get_repo_real_json` |
|
|
57
|
+
| List / fetch issues (PRs filtered from list) | `github_mcp/groups/read.py::list_issues`, `get_issue` | `tests/test_read.py::TestListIssues`, `TestGetIssue` |
|
|
58
|
+
| List / fetch pull requests | `github_mcp/groups/read.py::list_pull_requests`, `get_pull_request` | `tests/test_read.py::TestListPullRequests`, `TestGetPullRequest` |
|
|
59
|
+
| Read a repo file's content (base64-decoded, binary detected not decoded) | `github_mcp/groups/read.py::get_file_content` | `tests/test_read.py::TestGetFileContent` |
|
|
60
|
+
| Search public repositories | `github_mcp/groups/read.py::search_repos` | `tests/test_read.py::TestSearchRepos` |
|
|
61
|
+
| Public user/org profile | `github_mcp/groups/read.py::get_user` | `tests/test_read.py::TestGetUser` |
|
|
62
|
+
| List commits on a branch/ref | `github_mcp/groups/read.py::list_commits` | `tests/test_read.py::TestListCommits` |
|
|
63
|
+
| Open an issue | `github_mcp/groups/write.py::create_issue` | `tests/test_write.py::TestCreateIssue` |
|
|
64
|
+
| Comment on an issue/PR | `github_mcp/groups/write.py::comment_on_issue` | `tests/test_write.py::TestCommentOnIssue` |
|
|
65
|
+
| Open/close an issue | `github_mcp/groups/write.py::update_issue_state` | `tests/test_write.py::TestUpdateIssueState` |
|
|
66
|
+
| Add labels to an issue/PR | `github_mcp/groups/write.py::add_labels` | `tests/test_write.py::TestAddLabels` |
|
|
67
|
+
| Create a PR review comment on a diff line | `github_mcp/groups/write.py::create_pr_review_comment` | `tests/test_write.py::TestCreatePrReviewComment` |
|
|
68
|
+
| Write group OFF by default, structured refusal when disabled | `github_mcp/config.py::group_enabled`, `gated_write` | `tests/test_config.py::TestGroupEnabled`, `tests/test_write.py::TestGateDisabledByDefault` |
|
|
69
|
+
| Write tools require a token even when the group is enabled | `github_mcp/config.py::check_write_preconditions` | `tests/test_config.py::TestCheckWritePreconditions`, `tests/test_write.py::TestAuthRequiredWhenGroupEnabled` |
|
|
70
|
+
| Fine-grained PAT auth, degrades to unauthenticated tier when absent | `github_mcp/client.py::_headers` | `tests/test_client.py::TestAuthHeaderInjection`, `tests/test_read.py::TestUnauthDegrade` |
|
|
71
|
+
| GitHub primary rate-limit (403 + `X-RateLimit-Reset`) and secondary rate-limit (403 + `Retry-After`, no `X-RateLimit-Remaining`) both surface as a typed error with reset/retry time, never a crash | `github_mcp/client.py::_rate_limit_error`, `_is_rate_limit_response` | `tests/test_client.py::TestRateLimitError`, `tests/test_client.py::TestRateLimitError::test_secondary_rate_limit_no_ratelimit_headers_retry_after_only`, `tests/test_read.py::TestUnauthDegrade::test_get_repo_rate_limited_without_token_is_typed` |
|
|
72
|
+
| Malformed owner/repo/path (control chars etc.) that would raise `httpx.InvalidURL` surfaces as a typed error, never an uncaught exception | `github_mcp/client.py::request` | `tests/test_client.py::TestNetworkError::test_malformed_path_raises_invalid_url_caught_as_network_error` |
|
|
73
|
+
| Generic 4xx/5xx surfaces as a typed error, never a crash | `github_mcp/client.py::_api_error` | `tests/test_client.py::TestApiError` |
|
|
74
|
+
| Non-JSON / malformed responses and network failures surface as typed errors | `github_mcp/client.py::_handle_response`, `request` | `tests/test_client.py::TestDecodeError`, `TestNetworkError` |
|
|
75
|
+
|
|
76
|
+
## Limitations (read before relying on this)
|
|
77
|
+
|
|
78
|
+
- **REST v1 only.** No GraphQL API coverage.
|
|
79
|
+
- **No webhooks / GitHub App auth.** Fine-grained PAT only.
|
|
80
|
+
- **No Actions/workflow-dispatch tools.** Issue/PR CRUD is the v1 write surface.
|
|
81
|
+
- **Unauthenticated read is rate-limited to 60 req/hr** by GitHub itself (10
|
|
82
|
+
req/min for search) -- expect `rate_limited` errors under sustained
|
|
83
|
+
unauthenticated use; set `GITHUB_TOKEN` (even a read-only fine-grained PAT)
|
|
84
|
+
to raise this considerably.
|
|
85
|
+
- **`get_file_content` truncates past 100KB** and reports (rather than
|
|
86
|
+
decodes) non-UTF-8 files.
|
|
87
|
+
- **No pagination beyond a single page** for list endpoints (`limit`, capped
|
|
88
|
+
per-endpoint, is the only page-size control in v1).
|
|
89
|
+
- **Not registered with the mcp-factory hub.** Ships as a standalone repo
|
|
90
|
+
(own pyproject, system Python312 install), matching the rag-mcp/desktop-mcp
|
|
91
|
+
model.
|
|
92
|
+
|
|
93
|
+
## Env vars
|
|
94
|
+
|
|
95
|
+
| Var | Effect | Default |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `GITHUB_MCP_ENABLE_WRITE` | enable the `write` tool group | unset (off) |
|
|
98
|
+
| `GITHUB_TOKEN` | fine-grained PAT; read works without it (degraded unauth rate), write requires it | unset |
|
|
99
|
+
| `GITHUB_MCP_LIVE` | `1` to run the real-network smoke test (see Testing) | unset (skip) |
|
|
100
|
+
|
|
101
|
+
## Usage examples
|
|
102
|
+
|
|
103
|
+
```jsonc
|
|
104
|
+
// A tool call from the MCP host, illustrative -- not a shell command.
|
|
105
|
+
{"tool": "get_repo", "arguments": {"owner": "anthropics", "repo": "anthropic-sdk-python"}}
|
|
106
|
+
// -> {"ok": true, "full_name": "anthropics/anthropic-sdk-python", "stargazers_count": 1234, ...}
|
|
107
|
+
|
|
108
|
+
// write group disabled (default):
|
|
109
|
+
{"tool": "create_issue", "arguments": {"owner": "o", "repo": "r", "title": "bug"}}
|
|
110
|
+
// -> {"ok": false, "error": {"type": "policy_refusal", "group": "write", "required_env": "GITHUB_MCP_ENABLE_WRITE", ...}}
|
|
111
|
+
|
|
112
|
+
// write group enabled, no token set:
|
|
113
|
+
{"tool": "create_issue", "arguments": {"owner": "o", "repo": "r", "title": "bug"}}
|
|
114
|
+
// -> {"ok": false, "error": {"type": "auth_required", "tool": "create_issue", ...}}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Testing
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
# unit suite (respx-mocked api.github.com, no real network touched)
|
|
121
|
+
python -m pytest -q
|
|
122
|
+
|
|
123
|
+
# handshake check -- prints every registered tool name
|
|
124
|
+
python scripts/list_tools.py
|
|
125
|
+
|
|
126
|
+
# real-network read smoke (get_repo against a stable public repo;
|
|
127
|
+
# no write smoke exists anywhere in this suite -- see safety rails above)
|
|
128
|
+
GITHUB_MCP_LIVE=1 python -m pytest -q -k live_get_repo
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Install
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
pip install -r requirements.txt # or: pip install .
|
|
135
|
+
# deps: fastmcp==3.4.2, httpx==0.28.1
|
|
136
|
+
# test-only: pytest==9.0.3, respx==0.23.1
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Setup / connect
|
|
140
|
+
|
|
141
|
+
1. `pip install -r requirements.txt` on Python 3.12+.
|
|
142
|
+
2. (Optional) generate a [fine-grained PAT](https://github.com/settings/tokens?type=beta)
|
|
143
|
+
scoped to the repos you want read+write access to (Issues: read/write,
|
|
144
|
+
Pull requests: read/write, Contents: read is enough for v1). Read tools
|
|
145
|
+
work with **no token at all** -- they just run at GitHub's unauthenticated
|
|
146
|
+
60 req/hr tier.
|
|
147
|
+
3. Add to your MCP host config (e.g. `~/.claude.json`):
|
|
148
|
+
|
|
149
|
+
```jsonc
|
|
150
|
+
{
|
|
151
|
+
"mcpServers": {
|
|
152
|
+
"github-mcp": {
|
|
153
|
+
"command": "C:\\Users\\jaime\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
|
154
|
+
"args": ["C:\\Users\\jaime\\projects\\github-mcp\\run_server.py"],
|
|
155
|
+
"env": {
|
|
156
|
+
"GITHUB_TOKEN": "your-fine-grained-pat-here"
|
|
157
|
+
// GITHUB_MCP_ENABLE_WRITE intentionally absent -- write stays off
|
|
158
|
+
// until you deliberately opt in per-deployment.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
4. To enable write tools for a given deployment, add
|
|
166
|
+
`"GITHUB_MCP_ENABLE_WRITE": "1"` to that entry's `env` block. This is a
|
|
167
|
+
registration-time operator decision, not a code change.
|
|
168
|
+
|
|
169
|
+
Registered in `~/.claude.json` as `github-mcp` (stdio, system Python312,
|
|
170
|
+
`read` group always on, `write` group absent from env -- off).
|
|
171
|
+
|
|
172
|
+
<!-- MCP registry ownership marker -->
|
|
173
|
+
mcp-name: io.github.jaimenbell/github-mcp
|
|
File without changes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Thin sync httpx wrapper around api.github.com.
|
|
2
|
+
|
|
3
|
+
One function per HTTP verb used by the tool groups (get/post/patch). Auth
|
|
4
|
+
header injection when a token is present; degrades to GitHub's unauthenticated
|
|
5
|
+
60 req/hr tier otherwise. GitHub's 403 primary rate-limit response (hourly
|
|
6
|
+
quota, X-RateLimit-Reset) and secondary rate-limit response (abuse-detection
|
|
7
|
+
heuristics, Retry-After) both surface as a typed `rate_limited` error; any
|
|
8
|
+
other 4xx/5xx surfaces as a typed `github_api_error` -- never a raw
|
|
9
|
+
exception/crash. A dedicated httpx.Client is created per call so tests can
|
|
10
|
+
respx-mock deterministically without managing a shared client lifecycle
|
|
11
|
+
across the process.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json as json_module
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from . import config
|
|
21
|
+
|
|
22
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _headers() -> dict:
|
|
26
|
+
headers = {
|
|
27
|
+
"Accept": "application/vnd.github+json",
|
|
28
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
29
|
+
"User-Agent": "github-mcp",
|
|
30
|
+
}
|
|
31
|
+
token = config.get_token()
|
|
32
|
+
if token:
|
|
33
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
34
|
+
return headers
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _rate_limit_error(tool: str, response: httpx.Response) -> dict:
|
|
38
|
+
reset_header = response.headers.get("X-RateLimit-Reset")
|
|
39
|
+
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
40
|
+
retry_after = response.headers.get("Retry-After")
|
|
41
|
+
if reset_header:
|
|
42
|
+
message = f"GitHub API rate limit exceeded. Resets at unix time {reset_header}."
|
|
43
|
+
elif retry_after:
|
|
44
|
+
# GitHub's secondary rate limit: no X-RateLimit-* headers, just Retry-After.
|
|
45
|
+
message = f"GitHub API secondary rate limit exceeded. Retry after {retry_after}s."
|
|
46
|
+
else:
|
|
47
|
+
message = "GitHub API rate limit exceeded."
|
|
48
|
+
return {
|
|
49
|
+
"ok": False,
|
|
50
|
+
"error": {
|
|
51
|
+
"type": "rate_limited",
|
|
52
|
+
"message": message,
|
|
53
|
+
"tool": tool,
|
|
54
|
+
"status_code": response.status_code,
|
|
55
|
+
"reset_time": int(reset_header) if reset_header and reset_header.isdigit() else None,
|
|
56
|
+
"remaining": int(remaining) if remaining and remaining.isdigit() else None,
|
|
57
|
+
"retry_after_s": int(retry_after) if retry_after and retry_after.isdigit() else None,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _api_error(tool: str, response: httpx.Response) -> dict:
|
|
63
|
+
try:
|
|
64
|
+
body = response.json()
|
|
65
|
+
message = body.get("message", response.text)
|
|
66
|
+
except (json_module.JSONDecodeError, ValueError):
|
|
67
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
68
|
+
return {
|
|
69
|
+
"ok": False,
|
|
70
|
+
"error": {
|
|
71
|
+
"type": "github_api_error",
|
|
72
|
+
"message": message,
|
|
73
|
+
"tool": tool,
|
|
74
|
+
"status_code": response.status_code,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_rate_limit_response(response: httpx.Response) -> bool:
|
|
80
|
+
"""True for GitHub's primary rate limit (403 + X-RateLimit-Remaining: 0)
|
|
81
|
+
and its secondary rate limit (403 + Retry-After, no X-RateLimit-Remaining
|
|
82
|
+
-- triggered by abuse-detection/concurrency/rapid-write heuristics rather
|
|
83
|
+
than the hourly quota)."""
|
|
84
|
+
if response.status_code != 403:
|
|
85
|
+
return False
|
|
86
|
+
if response.headers.get("X-RateLimit-Remaining") == "0":
|
|
87
|
+
return True
|
|
88
|
+
return "Retry-After" in response.headers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _handle_response(tool: str, response: httpx.Response) -> dict:
|
|
92
|
+
if _is_rate_limit_response(response):
|
|
93
|
+
return _rate_limit_error(tool, response)
|
|
94
|
+
if response.status_code >= 400:
|
|
95
|
+
return _api_error(tool, response)
|
|
96
|
+
try:
|
|
97
|
+
data = response.json() if response.content else {}
|
|
98
|
+
except (json_module.JSONDecodeError, ValueError) as exc:
|
|
99
|
+
return {
|
|
100
|
+
"ok": False,
|
|
101
|
+
"error": {
|
|
102
|
+
"type": "decode_error",
|
|
103
|
+
"message": f"GitHub returned non-JSON content: {exc}",
|
|
104
|
+
"tool": tool,
|
|
105
|
+
"status_code": response.status_code,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
return {"ok": True, "data": data, "status_code": response.status_code}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def request(
|
|
112
|
+
tool: str,
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
params: dict[str, Any] | None = None,
|
|
117
|
+
json: dict[str, Any] | None = None,
|
|
118
|
+
) -> dict:
|
|
119
|
+
"""Issue one request to api.github.com and return a structured result:
|
|
120
|
+
{"ok": True, "data": ..., "status_code": ...} on success, or
|
|
121
|
+
{"ok": False, "error": {...}} on any 4xx/5xx/decode failure. Network-level
|
|
122
|
+
exceptions (timeout, connection refused, DNS failure) AND malformed-URL
|
|
123
|
+
errors (e.g. a caller-supplied owner/repo/path containing characters that
|
|
124
|
+
make the assembled URL invalid) are also caught and surfaced as a typed
|
|
125
|
+
error rather than propagating -- the tool caller always gets a dict back,
|
|
126
|
+
never an exception. `httpx.InvalidURL` does not subclass `httpx.HTTPError`
|
|
127
|
+
(it's raised during URL construction, before any network I/O), so it is
|
|
128
|
+
listed explicitly -- omitting it would let a bad path/owner/repo value
|
|
129
|
+
crash the call instead of returning a clean error."""
|
|
130
|
+
url = f"{config.GITHUB_API_BASE}{path}"
|
|
131
|
+
try:
|
|
132
|
+
with httpx.Client(timeout=DEFAULT_TIMEOUT_S) as client:
|
|
133
|
+
response = client.request(method, url, headers=_headers(), params=params, json=json)
|
|
134
|
+
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
135
|
+
return {
|
|
136
|
+
"ok": False,
|
|
137
|
+
"error": {
|
|
138
|
+
"type": "network_error",
|
|
139
|
+
"message": str(exc),
|
|
140
|
+
"tool": tool,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
return _handle_response(tool, response)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get(tool: str, path: str, *, params: dict[str, Any] | None = None) -> dict:
|
|
147
|
+
return request(tool, "GET", path, params=params)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def post(tool: str, path: str, *, json: dict[str, Any] | None = None) -> dict:
|
|
151
|
+
return request(tool, "POST", path, json=json)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def patch(tool: str, path: str, *, json: dict[str, Any] | None = None) -> dict:
|
|
155
|
+
return request(tool, "PATCH", path, json=json)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Config/safety layer for github-mcp.
|
|
2
|
+
|
|
3
|
+
Tool-group gating + token loading + structured refusal/error payloads. This
|
|
4
|
+
is the server's own defense-in-depth layer: even if a caller gets past
|
|
5
|
+
harness permission prompts, the server itself refuses write actions unless
|
|
6
|
+
GITHUB_MCP_ENABLE_WRITE=1 is set, and never proceeds with a write call that
|
|
7
|
+
lacks a token -- mirroring desktop-mcp's config.gated pattern.
|
|
8
|
+
|
|
9
|
+
Groups:
|
|
10
|
+
read -- repo/issue/PR/file/user/commit lookups (always on, works
|
|
11
|
+
unauthenticated at GitHub's 60 req/hr tier)
|
|
12
|
+
write -- issue/PR-comment mutations (env-gated, OFF by default, requires
|
|
13
|
+
GITHUB_TOKEN)
|
|
14
|
+
|
|
15
|
+
Env vars:
|
|
16
|
+
GITHUB_MCP_ENABLE_WRITE=1 -- enable the write group
|
|
17
|
+
GITHUB_TOKEN -- fine-grained PAT; read works without it
|
|
18
|
+
(degraded unauth rate), write requires it
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
GROUP_READ = "read"
|
|
25
|
+
GROUP_WRITE = "write"
|
|
26
|
+
|
|
27
|
+
_ENV_GATES = {
|
|
28
|
+
GROUP_WRITE: "GITHUB_MCP_ENABLE_WRITE",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _env_truthy(name: str) -> bool:
|
|
35
|
+
val = os.environ.get(name, "")
|
|
36
|
+
return val.strip().lower() in ("1", "true", "yes", "on")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def group_enabled(group: str) -> bool:
|
|
40
|
+
"""read is always on; write requires its env gate."""
|
|
41
|
+
if group == GROUP_READ:
|
|
42
|
+
return True
|
|
43
|
+
env_name = _ENV_GATES.get(group)
|
|
44
|
+
if env_name is None:
|
|
45
|
+
return False
|
|
46
|
+
return _env_truthy(env_name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_token() -> str | None:
|
|
50
|
+
"""Fine-grained PAT from GITHUB_TOKEN, or None. Never logged; callers
|
|
51
|
+
must not include this value in any error payload or diagnostic."""
|
|
52
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
53
|
+
return token if token else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def policy_refusal(group: str, tool: str) -> dict:
|
|
57
|
+
"""Structured refusal payload for a disabled tool group."""
|
|
58
|
+
env_name = _ENV_GATES.get(group, f"GITHUB_MCP_ENABLE_{group.upper()}")
|
|
59
|
+
return {
|
|
60
|
+
"ok": False,
|
|
61
|
+
"error": {
|
|
62
|
+
"type": "policy_refusal",
|
|
63
|
+
"message": (
|
|
64
|
+
f"Tool group '{group}' is disabled. Set {env_name}=1 in the "
|
|
65
|
+
f"server's environment to enable it."
|
|
66
|
+
),
|
|
67
|
+
"group": group,
|
|
68
|
+
"tool": tool,
|
|
69
|
+
"required_env": env_name,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def auth_required(tool: str) -> dict:
|
|
75
|
+
"""Structured refusal payload for a write call with no GITHUB_TOKEN.
|
|
76
|
+
Write actions always require a token even when the group is enabled --
|
|
77
|
+
GitHub's write endpoints reject unauthenticated requests, so this is a
|
|
78
|
+
fast, clean local refusal instead of a round-trip 401/403."""
|
|
79
|
+
return {
|
|
80
|
+
"ok": False,
|
|
81
|
+
"error": {
|
|
82
|
+
"type": "auth_required",
|
|
83
|
+
"message": (
|
|
84
|
+
f"Tool '{tool}' requires a GitHub token. Set GITHUB_TOKEN in "
|
|
85
|
+
f"the server's environment (fine-grained PAT with the needed "
|
|
86
|
+
f"repo write scopes)."
|
|
87
|
+
),
|
|
88
|
+
"tool": tool,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_group(group: str, tool: str) -> dict | None:
|
|
94
|
+
"""Gate check for a tool call. Returns a structured refusal dict if the
|
|
95
|
+
group is disabled, else None (caller proceeds)."""
|
|
96
|
+
if not group_enabled(group):
|
|
97
|
+
return policy_refusal(group, tool)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_write_preconditions(tool: str) -> dict | None:
|
|
102
|
+
"""Combined gate for write tools: group must be enabled AND a token must
|
|
103
|
+
be present. Returns a structured refusal dict, else None."""
|
|
104
|
+
refusal = check_group(GROUP_WRITE, tool)
|
|
105
|
+
if refusal is not None:
|
|
106
|
+
return refusal
|
|
107
|
+
if get_token() is None:
|
|
108
|
+
return auth_required(tool)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def gated_write(fn):
|
|
113
|
+
"""Decorator applied directly to write-group module functions so the
|
|
114
|
+
policy gate + token precondition is enforced at the source -- not just in
|
|
115
|
+
the MCP tool wrapper -- and is unit-testable without spinning up the
|
|
116
|
+
fastmcp server or hitting the network."""
|
|
117
|
+
|
|
118
|
+
def wrapper(*args, **kwargs):
|
|
119
|
+
refusal = check_write_preconditions(fn.__name__)
|
|
120
|
+
if refusal is not None:
|
|
121
|
+
return refusal
|
|
122
|
+
return fn(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
wrapper.__name__ = fn.__name__
|
|
125
|
+
wrapper.__doc__ = fn.__doc__
|
|
126
|
+
wrapper.__wrapped__ = fn
|
|
127
|
+
return wrapper
|
|
File without changes
|