github-mcp 1.1.0__py3-none-any.whl
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.
- github_mcp/__init__.py +3 -0
- github_mcp/app.py +3 -0
- github_mcp/client.py +252 -0
- github_mcp/formatting.py +144 -0
- github_mcp/server.py +17 -0
- github_mcp/tools/__init__.py +1 -0
- github_mcp/tools/issues.py +375 -0
- github_mcp/tools/pulls.py +429 -0
- github_mcp/tools/repos.py +686 -0
- github_mcp/tools/search.py +524 -0
- github_mcp-1.1.0.dist-info/METADATA +193 -0
- github_mcp-1.1.0.dist-info/RECORD +15 -0
- github_mcp-1.1.0.dist-info/WHEEL +4 -0
- github_mcp-1.1.0.dist-info/entry_points.txt +3 -0
- github_mcp-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from github_mcp.app import mcp
|
|
9
|
+
from mcp.types import ToolAnnotations
|
|
10
|
+
from github_mcp.client import github_request, handle_github_error
|
|
11
|
+
from github_mcp.formatting import (
|
|
12
|
+
ResponseFormat,
|
|
13
|
+
fmt_json,
|
|
14
|
+
fmt_list_markdown,
|
|
15
|
+
fmt_timestamp,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# MARK: Input models
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ListReposInput(BaseModel):
|
|
22
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
23
|
+
|
|
24
|
+
owner: Optional[str] = Field(
|
|
25
|
+
default=None,
|
|
26
|
+
description=(
|
|
27
|
+
"GitHub username or org whose repos to list. "
|
|
28
|
+
"Leave empty to list repos for the authenticated user."
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
type: Optional[str] = Field(
|
|
32
|
+
default="all",
|
|
33
|
+
description="Filter by type: 'all', 'public', 'private', 'forks', 'sources', 'member'. Default 'all'.",
|
|
34
|
+
)
|
|
35
|
+
sort: Optional[str] = Field(
|
|
36
|
+
default="updated",
|
|
37
|
+
description="Sort key: 'created', 'updated', 'pushed', 'full_name'. Default 'updated'.",
|
|
38
|
+
)
|
|
39
|
+
limit: int = Field(
|
|
40
|
+
default=20,
|
|
41
|
+
ge=1,
|
|
42
|
+
le=100,
|
|
43
|
+
description="Maximum repos to return (1–100, default 20).",
|
|
44
|
+
)
|
|
45
|
+
page: int = Field(
|
|
46
|
+
default=1, ge=1, description="Page number for pagination (default 1)."
|
|
47
|
+
)
|
|
48
|
+
response_format: ResponseFormat = Field(
|
|
49
|
+
default=ResponseFormat.MARKDOWN,
|
|
50
|
+
description="Output format: 'markdown' (default) or 'json'.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GetRepoInput(BaseModel):
|
|
55
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
56
|
+
|
|
57
|
+
owner: str = Field(
|
|
58
|
+
...,
|
|
59
|
+
description="Repository owner (username or org, e.g. 'octocat').",
|
|
60
|
+
min_length=1,
|
|
61
|
+
)
|
|
62
|
+
repo: str = Field(
|
|
63
|
+
..., description="Repository name (e.g. 'Hello-World').", min_length=1
|
|
64
|
+
)
|
|
65
|
+
response_format: ResponseFormat = Field(
|
|
66
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ListBranchesInput(BaseModel):
|
|
71
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
72
|
+
|
|
73
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
74
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
75
|
+
limit: int = Field(
|
|
76
|
+
default=20, ge=1, le=100, description="Maximum branches to return."
|
|
77
|
+
)
|
|
78
|
+
page: int = Field(default=1, ge=1, description="Page number.")
|
|
79
|
+
response_format: ResponseFormat = Field(
|
|
80
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ListCommitsInput(BaseModel):
|
|
85
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
86
|
+
|
|
87
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
88
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
89
|
+
sha: Optional[str] = Field(
|
|
90
|
+
default=None,
|
|
91
|
+
description="Branch, tag, or commit SHA to list commits from. Defaults to the repo's default branch.",
|
|
92
|
+
)
|
|
93
|
+
path: Optional[str] = Field(
|
|
94
|
+
default=None,
|
|
95
|
+
description="Only return commits that touch this file/directory path.",
|
|
96
|
+
)
|
|
97
|
+
since: Optional[str] = Field(
|
|
98
|
+
default=None,
|
|
99
|
+
description="ISO 8601 timestamp — return commits after this date (e.g. '2024-01-01T00:00:00Z').",
|
|
100
|
+
)
|
|
101
|
+
limit: int = Field(
|
|
102
|
+
default=20, ge=1, le=100, description="Maximum commits to return."
|
|
103
|
+
)
|
|
104
|
+
page: int = Field(default=1, ge=1, description="Page number.")
|
|
105
|
+
response_format: ResponseFormat = Field(
|
|
106
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GetFileContentsInput(BaseModel):
|
|
111
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
112
|
+
|
|
113
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
114
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
115
|
+
path: str = Field(
|
|
116
|
+
...,
|
|
117
|
+
description="Path to the file within the repository (e.g. 'src/main.py').",
|
|
118
|
+
min_length=1,
|
|
119
|
+
)
|
|
120
|
+
ref: Optional[str] = Field(
|
|
121
|
+
default=None,
|
|
122
|
+
description="Branch, tag, or commit SHA to read from. Defaults to the repo's default branch.",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ListDirectoryInput(BaseModel):
|
|
127
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
128
|
+
|
|
129
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
130
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
131
|
+
path: str = Field(
|
|
132
|
+
default="",
|
|
133
|
+
description="Directory path within the repo. Use '' or '.' for the root.",
|
|
134
|
+
)
|
|
135
|
+
ref: Optional[str] = Field(
|
|
136
|
+
default=None,
|
|
137
|
+
description="Branch, tag, or commit SHA. Defaults to the repo's default branch.",
|
|
138
|
+
)
|
|
139
|
+
response_format: ResponseFormat = Field(
|
|
140
|
+
default=ResponseFormat.MARKDOWN, description="Output format."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CreateOrUpdateFileInput(BaseModel):
|
|
145
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
146
|
+
|
|
147
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
148
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
149
|
+
path: str = Field(
|
|
150
|
+
...,
|
|
151
|
+
description="Path of the file to create or update (e.g. 'docs/guide.md').",
|
|
152
|
+
min_length=1,
|
|
153
|
+
)
|
|
154
|
+
message: str = Field(..., description="Commit message.", min_length=1)
|
|
155
|
+
content: str = Field(
|
|
156
|
+
...,
|
|
157
|
+
description="New file content (plain text — will be base64-encoded automatically).",
|
|
158
|
+
)
|
|
159
|
+
sha: Optional[str] = Field(
|
|
160
|
+
default=None,
|
|
161
|
+
description=(
|
|
162
|
+
"Blob SHA of the existing file (required when updating). "
|
|
163
|
+
"Obtain via github_get_file_contents. Omit when creating a new file."
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
branch: Optional[str] = Field(
|
|
167
|
+
default=None,
|
|
168
|
+
description="Branch to commit to. Defaults to the repo's default branch.",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class CreateBranchInput(BaseModel):
|
|
173
|
+
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
|
|
174
|
+
|
|
175
|
+
owner: str = Field(..., description="Repository owner.", min_length=1)
|
|
176
|
+
repo: str = Field(..., description="Repository name.", min_length=1)
|
|
177
|
+
branch: str = Field(
|
|
178
|
+
..., description="Name of the new branch to create.", min_length=1
|
|
179
|
+
)
|
|
180
|
+
base_ref: str = Field(
|
|
181
|
+
...,
|
|
182
|
+
description="Branch, tag, or commit SHA to branch from (e.g. 'main', 'v1.2.0').",
|
|
183
|
+
min_length=1,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# MARK: Tools
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.tool(
|
|
191
|
+
name="github_list_repos",
|
|
192
|
+
annotations=ToolAnnotations(
|
|
193
|
+
title="List GitHub Repositories",
|
|
194
|
+
readOnlyHint=True,
|
|
195
|
+
destructiveHint=False,
|
|
196
|
+
idempotentHint=True,
|
|
197
|
+
openWorldHint=True,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
async def github_list_repos(params: ListReposInput) -> str:
|
|
201
|
+
"""List repositories for the authenticated user or a specific owner/org.
|
|
202
|
+
|
|
203
|
+
Returns a paginated list of repositories with key metadata.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
params (ListReposInput): Validated input containing:
|
|
207
|
+
- owner (Optional[str]): GitHub username or org. Empty = authenticated user.
|
|
208
|
+
- type (Optional[str]): Filter type ('all', 'public', 'private', etc.)
|
|
209
|
+
- sort (Optional[str]): Sort key ('updated', 'created', etc.)
|
|
210
|
+
- limit (int): Max repos to return (1–100).
|
|
211
|
+
- page (int): Page number.
|
|
212
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
str: Formatted list of repositories. Markdown includes name, description,
|
|
216
|
+
language, stars, forks, visibility, and URL. JSON is the raw API array.
|
|
217
|
+
|
|
218
|
+
Error response: "Error <code>: <message>" string.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
if params.owner:
|
|
222
|
+
path = f"/users/{params.owner}/repos"
|
|
223
|
+
else:
|
|
224
|
+
path = "/user/repos"
|
|
225
|
+
|
|
226
|
+
query: dict = {
|
|
227
|
+
"type": params.type,
|
|
228
|
+
"sort": params.sort,
|
|
229
|
+
"per_page": params.limit,
|
|
230
|
+
"page": params.page,
|
|
231
|
+
}
|
|
232
|
+
resp = await github_request(
|
|
233
|
+
"GET", path, params={k: v for k, v in query.items() if v is not None}
|
|
234
|
+
)
|
|
235
|
+
repos = resp.json()
|
|
236
|
+
|
|
237
|
+
if params.response_format == ResponseFormat.JSON:
|
|
238
|
+
return fmt_json(repos)
|
|
239
|
+
|
|
240
|
+
return fmt_list_markdown(
|
|
241
|
+
repos,
|
|
242
|
+
title=f"Repositories for {params.owner or 'you'}",
|
|
243
|
+
fields=[
|
|
244
|
+
"description",
|
|
245
|
+
"language",
|
|
246
|
+
"stargazers_count",
|
|
247
|
+
"forks_count",
|
|
248
|
+
"visibility",
|
|
249
|
+
"updated_at",
|
|
250
|
+
],
|
|
251
|
+
name_field="full_name",
|
|
252
|
+
)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
return handle_github_error(e)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@mcp.tool(
|
|
258
|
+
name="github_get_repo",
|
|
259
|
+
annotations=ToolAnnotations(
|
|
260
|
+
title="Get GitHub Repository",
|
|
261
|
+
readOnlyHint=True,
|
|
262
|
+
destructiveHint=False,
|
|
263
|
+
idempotentHint=True,
|
|
264
|
+
openWorldHint=True,
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
async def github_get_repo(params: GetRepoInput) -> str:
|
|
268
|
+
"""Get full metadata for a single GitHub repository.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
params (GetRepoInput): Validated input containing:
|
|
272
|
+
- owner (str): Repository owner.
|
|
273
|
+
- repo (str): Repository name.
|
|
274
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
str: Repository details. Markdown shows name, description, language,
|
|
278
|
+
stars, forks, open issues, default branch, topics, license, and URL.
|
|
279
|
+
JSON is the full raw API object.
|
|
280
|
+
|
|
281
|
+
Error response: "Error <code>: <message>" string.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
resp = await github_request("GET", f"/repos/{params.owner}/{params.repo}")
|
|
285
|
+
repo = resp.json()
|
|
286
|
+
|
|
287
|
+
if params.response_format == ResponseFormat.JSON:
|
|
288
|
+
return fmt_json(repo)
|
|
289
|
+
|
|
290
|
+
lines = [
|
|
291
|
+
f"## {repo.get('full_name', '')}",
|
|
292
|
+
"",
|
|
293
|
+
f"{repo.get('description') or '*No description*'}",
|
|
294
|
+
"",
|
|
295
|
+
f"- **URL**: {repo.get('html_url', '')}",
|
|
296
|
+
f"- **Language**: {repo.get('language') or 'N/A'}",
|
|
297
|
+
f"- **Stars**: {repo.get('stargazers_count', 0):,}",
|
|
298
|
+
f"- **Forks**: {repo.get('forks_count', 0):,}",
|
|
299
|
+
f"- **Open Issues**: {repo.get('open_issues_count', 0):,}",
|
|
300
|
+
f"- **Default Branch**: {repo.get('default_branch', 'main')}",
|
|
301
|
+
f"- **Visibility**: {repo.get('visibility', 'N/A')}",
|
|
302
|
+
f"- **Created**: {fmt_timestamp(repo.get('created_at'))}",
|
|
303
|
+
f"- **Updated**: {fmt_timestamp(repo.get('updated_at'))}",
|
|
304
|
+
f"- **Pushed**: {fmt_timestamp(repo.get('pushed_at'))}",
|
|
305
|
+
]
|
|
306
|
+
if repo.get("license"):
|
|
307
|
+
lines.append(
|
|
308
|
+
f"- **License**: {repo['license'].get('spdx_id', repo['license'].get('name', 'N/A'))}"
|
|
309
|
+
)
|
|
310
|
+
topics = repo.get("topics", [])
|
|
311
|
+
if topics:
|
|
312
|
+
lines.append(f"- **Topics**: {', '.join(topics)}")
|
|
313
|
+
return "\n".join(lines)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
return handle_github_error(e)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@mcp.tool(
|
|
319
|
+
name="github_list_branches",
|
|
320
|
+
annotations=ToolAnnotations(
|
|
321
|
+
title="List Repository Branches",
|
|
322
|
+
readOnlyHint=True,
|
|
323
|
+
destructiveHint=False,
|
|
324
|
+
idempotentHint=True,
|
|
325
|
+
openWorldHint=True,
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
async def github_list_branches(params: ListBranchesInput) -> str:
|
|
329
|
+
"""List branches in a GitHub repository.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
params (ListBranchesInput): Validated input containing:
|
|
333
|
+
- owner (str): Repository owner.
|
|
334
|
+
- repo (str): Repository name.
|
|
335
|
+
- limit (int): Max branches to return (1–100).
|
|
336
|
+
- page (int): Page number.
|
|
337
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
str: Formatted branch list. Markdown shows branch name and latest commit SHA.
|
|
341
|
+
JSON is the raw API array.
|
|
342
|
+
|
|
343
|
+
Error response: "Error <code>: <message>" string.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
resp = await github_request(
|
|
347
|
+
"GET",
|
|
348
|
+
f"/repos/{params.owner}/{params.repo}/branches",
|
|
349
|
+
params={"per_page": params.limit, "page": params.page},
|
|
350
|
+
)
|
|
351
|
+
branches = resp.json()
|
|
352
|
+
|
|
353
|
+
if params.response_format == ResponseFormat.JSON:
|
|
354
|
+
return fmt_json(branches)
|
|
355
|
+
|
|
356
|
+
lines = [f"## Branches: {params.owner}/{params.repo}", ""]
|
|
357
|
+
if not branches:
|
|
358
|
+
lines.append("*No branches found.*")
|
|
359
|
+
else:
|
|
360
|
+
for branch in branches:
|
|
361
|
+
sha = branch.get("commit", {}).get("sha", "")[:7]
|
|
362
|
+
protected = " 🔒" if branch.get("protected") else ""
|
|
363
|
+
lines.append(f"- **{branch['name']}**{protected} — `{sha}`")
|
|
364
|
+
return "\n".join(lines)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
return handle_github_error(e)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@mcp.tool(
|
|
370
|
+
name="github_list_commits",
|
|
371
|
+
annotations=ToolAnnotations(
|
|
372
|
+
title="List Repository Commits",
|
|
373
|
+
readOnlyHint=True,
|
|
374
|
+
destructiveHint=False,
|
|
375
|
+
idempotentHint=True,
|
|
376
|
+
openWorldHint=True,
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
async def github_list_commits(params: ListCommitsInput) -> str:
|
|
380
|
+
"""List commits in a repository, optionally filtered by branch/path/date.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
params (ListCommitsInput): Validated input containing:
|
|
384
|
+
- owner (str): Repository owner.
|
|
385
|
+
- repo (str): Repository name.
|
|
386
|
+
- sha (Optional[str]): Branch, tag, or commit SHA to start from.
|
|
387
|
+
- path (Optional[str]): Only commits touching this path.
|
|
388
|
+
- since (Optional[str]): ISO 8601 date — commits after this time.
|
|
389
|
+
- limit (int): Max commits (1–100).
|
|
390
|
+
- page (int): Page number.
|
|
391
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
str: Formatted commit list. Markdown shows SHA (short), author, date,
|
|
395
|
+
and commit message. JSON is the raw API array.
|
|
396
|
+
|
|
397
|
+
Error response: "Error <code>: <message>" string.
|
|
398
|
+
"""
|
|
399
|
+
try:
|
|
400
|
+
query: dict = {"per_page": params.limit, "page": params.page}
|
|
401
|
+
if params.sha:
|
|
402
|
+
query["sha"] = params.sha
|
|
403
|
+
if params.path:
|
|
404
|
+
query["path"] = params.path
|
|
405
|
+
if params.since:
|
|
406
|
+
query["since"] = params.since
|
|
407
|
+
|
|
408
|
+
resp = await github_request(
|
|
409
|
+
"GET",
|
|
410
|
+
f"/repos/{params.owner}/{params.repo}/commits",
|
|
411
|
+
params=query,
|
|
412
|
+
)
|
|
413
|
+
commits = resp.json()
|
|
414
|
+
|
|
415
|
+
if params.response_format == ResponseFormat.JSON:
|
|
416
|
+
return fmt_json(commits)
|
|
417
|
+
|
|
418
|
+
lines = [f"## Commits: {params.owner}/{params.repo}", ""]
|
|
419
|
+
if not commits:
|
|
420
|
+
lines.append("*No commits found.*")
|
|
421
|
+
else:
|
|
422
|
+
for commit in commits:
|
|
423
|
+
sha = commit["sha"][:7]
|
|
424
|
+
msg = commit["commit"]["message"].split("\n")[0][:80]
|
|
425
|
+
author = commit["commit"]["author"].get("name", "unknown")
|
|
426
|
+
date = fmt_timestamp(commit["commit"]["author"].get("date"))
|
|
427
|
+
url = commit.get("html_url", "")
|
|
428
|
+
if url:
|
|
429
|
+
lines.append(f"- [`{sha}`]({url}) {msg}")
|
|
430
|
+
else:
|
|
431
|
+
lines.append(f"- `{sha}` {msg}")
|
|
432
|
+
lines.append(f" - Author: {author} — {date}")
|
|
433
|
+
return "\n".join(lines)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
return handle_github_error(e)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@mcp.tool(
|
|
439
|
+
name="github_get_file_contents",
|
|
440
|
+
annotations=ToolAnnotations(
|
|
441
|
+
title="Get File Contents from Repository",
|
|
442
|
+
readOnlyHint=True,
|
|
443
|
+
destructiveHint=False,
|
|
444
|
+
idempotentHint=True,
|
|
445
|
+
openWorldHint=True,
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
async def github_get_file_contents(params: GetFileContentsInput) -> str:
|
|
449
|
+
"""Read the decoded text content of a file in a GitHub repository.
|
|
450
|
+
|
|
451
|
+
Also returns the blob SHA needed for subsequent updates.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
params (GetFileContentsInput): Validated input containing:
|
|
455
|
+
- owner (str): Repository owner.
|
|
456
|
+
- repo (str): Repository name.
|
|
457
|
+
- path (str): File path within the repo (e.g. 'src/main.py').
|
|
458
|
+
- ref (Optional[str]): Branch, tag, or commit SHA.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
str: A header line with path, ref, SHA and size, followed by the
|
|
462
|
+
decoded file content. Format:
|
|
463
|
+
```
|
|
464
|
+
# path/to/file (ref: main | sha: abc1234 | size: 1234 bytes)
|
|
465
|
+
<file content>
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Error response: "Error <code>: <message>" string.
|
|
469
|
+
"Error: Path ... is a directory" if path points to a directory.
|
|
470
|
+
"""
|
|
471
|
+
try:
|
|
472
|
+
query: dict = {}
|
|
473
|
+
if params.ref:
|
|
474
|
+
query["ref"] = params.ref
|
|
475
|
+
|
|
476
|
+
resp = await github_request(
|
|
477
|
+
"GET",
|
|
478
|
+
f"/repos/{params.owner}/{params.repo}/contents/{params.path.lstrip('/')}",
|
|
479
|
+
params=query or None,
|
|
480
|
+
)
|
|
481
|
+
data = resp.json()
|
|
482
|
+
|
|
483
|
+
if isinstance(data, list):
|
|
484
|
+
return f"Error: Path '{params.path}' is a directory. Use github_list_directory instead."
|
|
485
|
+
|
|
486
|
+
if data.get("type") != "file":
|
|
487
|
+
return f"Error: Path '{params.path}' is not a regular file (type: {data.get('type')})."
|
|
488
|
+
|
|
489
|
+
encoded = data.get("content", "")
|
|
490
|
+
decoded = base64.b64decode(encoded).decode("utf-8", errors="replace")
|
|
491
|
+
sha = data.get("sha", "")
|
|
492
|
+
size = data.get("size", 0)
|
|
493
|
+
used_ref = params.ref or "default branch"
|
|
494
|
+
|
|
495
|
+
header = f"# {params.path} (ref: {used_ref} | sha: {sha} | size: {size} bytes)"
|
|
496
|
+
return f"{header}\n\n{decoded}"
|
|
497
|
+
except Exception as e:
|
|
498
|
+
return handle_github_error(e)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@mcp.tool(
|
|
502
|
+
name="github_list_directory",
|
|
503
|
+
annotations=ToolAnnotations(
|
|
504
|
+
title="List Repository Directory",
|
|
505
|
+
readOnlyHint=True,
|
|
506
|
+
destructiveHint=False,
|
|
507
|
+
idempotentHint=True,
|
|
508
|
+
openWorldHint=True,
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
async def github_list_directory(params: ListDirectoryInput) -> str:
|
|
512
|
+
"""List files and subdirectories at a path in a GitHub repository.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
params (ListDirectoryInput): Validated input containing:
|
|
516
|
+
- owner (str): Repository owner.
|
|
517
|
+
- repo (str): Repository name.
|
|
518
|
+
- path (str): Directory path (use '' or '.' for root).
|
|
519
|
+
- ref (Optional[str]): Branch, tag, or commit SHA.
|
|
520
|
+
- response_format (ResponseFormat): 'markdown' or 'json'.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
str: Markdown or JSON listing. Markdown shows each entry as a bullet
|
|
524
|
+
with name, type (file/dir), and size for files.
|
|
525
|
+
|
|
526
|
+
Error response: "Error <code>: <message>" string.
|
|
527
|
+
"""
|
|
528
|
+
try:
|
|
529
|
+
query: dict = {}
|
|
530
|
+
if params.ref:
|
|
531
|
+
query["ref"] = params.ref
|
|
532
|
+
|
|
533
|
+
# Normalize path
|
|
534
|
+
clean_path = params.path.strip("/").strip() or ""
|
|
535
|
+
api_path = (
|
|
536
|
+
f"/repos/{params.owner}/{params.repo}/contents/{clean_path}"
|
|
537
|
+
if clean_path
|
|
538
|
+
else f"/repos/{params.owner}/{params.repo}/contents"
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
resp = await github_request("GET", api_path, params=query or None)
|
|
542
|
+
entries = resp.json()
|
|
543
|
+
|
|
544
|
+
if not isinstance(entries, list):
|
|
545
|
+
return f"Error: Path '{params.path}' is a file, not a directory. Use github_get_file_contents instead."
|
|
546
|
+
|
|
547
|
+
if params.response_format == ResponseFormat.JSON:
|
|
548
|
+
return fmt_json(entries)
|
|
549
|
+
|
|
550
|
+
display_path = params.path or "/"
|
|
551
|
+
lines = [
|
|
552
|
+
f"## Directory listing: {params.owner}/{params.repo} @ {display_path}",
|
|
553
|
+
"",
|
|
554
|
+
]
|
|
555
|
+
if not entries:
|
|
556
|
+
lines.append("*Empty directory.*")
|
|
557
|
+
else:
|
|
558
|
+
dirs = [e for e in entries if e.get("type") == "dir"]
|
|
559
|
+
files = [e for e in entries if e.get("type") == "file"]
|
|
560
|
+
for entry in sorted(dirs, key=lambda x: x.get("name", "")):
|
|
561
|
+
lines.append(f"- 📁 **{entry['name']}/**")
|
|
562
|
+
for entry in sorted(files, key=lambda x: x.get("name", "")):
|
|
563
|
+
size = entry.get("size", 0)
|
|
564
|
+
lines.append(f"- 📄 {entry['name']} ({size:,} bytes)")
|
|
565
|
+
return "\n".join(lines)
|
|
566
|
+
except Exception as e:
|
|
567
|
+
return handle_github_error(e)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@mcp.tool(
|
|
571
|
+
name="github_create_or_update_file",
|
|
572
|
+
annotations=ToolAnnotations(
|
|
573
|
+
title="Create or Update a File in Repository",
|
|
574
|
+
readOnlyHint=False,
|
|
575
|
+
destructiveHint=True,
|
|
576
|
+
idempotentHint=False,
|
|
577
|
+
openWorldHint=True,
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
async def github_create_or_update_file(params: CreateOrUpdateFileInput) -> str:
|
|
581
|
+
"""Create a new file or update an existing file in a GitHub repository.
|
|
582
|
+
|
|
583
|
+
For updates, the current blob SHA (from github_get_file_contents) must be
|
|
584
|
+
provided to prevent accidental overwrites.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
params (CreateOrUpdateFileInput): Validated input containing:
|
|
588
|
+
- owner (str): Repository owner.
|
|
589
|
+
- repo (str): Repository name.
|
|
590
|
+
- path (str): File path to create/update.
|
|
591
|
+
- message (str): Commit message.
|
|
592
|
+
- content (str): New plain-text content (auto base64-encoded).
|
|
593
|
+
- sha (Optional[str]): Existing blob SHA (required for updates).
|
|
594
|
+
- branch (Optional[str]): Target branch (defaults to repo default).
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
str: Confirmation with commit SHA, URL, and file path on success.
|
|
598
|
+
|
|
599
|
+
Error response: "Error <code>: <message>" string.
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
encoded_content = base64.b64encode(params.content.encode("utf-8")).decode(
|
|
603
|
+
"ascii"
|
|
604
|
+
)
|
|
605
|
+
body: dict = {"message": params.message, "content": encoded_content}
|
|
606
|
+
if params.sha:
|
|
607
|
+
body["sha"] = params.sha
|
|
608
|
+
if params.branch:
|
|
609
|
+
body["branch"] = params.branch
|
|
610
|
+
|
|
611
|
+
resp = await github_request(
|
|
612
|
+
"PUT",
|
|
613
|
+
f"/repos/{params.owner}/{params.repo}/contents/{params.path.lstrip('/')}",
|
|
614
|
+
json=body,
|
|
615
|
+
)
|
|
616
|
+
data = resp.json()
|
|
617
|
+
commit = data.get("commit", {})
|
|
618
|
+
commit_sha = commit.get("sha", "")[:7]
|
|
619
|
+
commit_url = commit.get("html_url", "")
|
|
620
|
+
action = "Updated" if params.sha else "Created"
|
|
621
|
+
return (
|
|
622
|
+
f"{action} file `{params.path}` on branch `{params.branch or 'default'}`.\n"
|
|
623
|
+
f"Commit: [{commit_sha}]({commit_url}) — {params.message}"
|
|
624
|
+
)
|
|
625
|
+
except Exception as e:
|
|
626
|
+
return handle_github_error(e)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@mcp.tool(
|
|
630
|
+
name="github_create_branch",
|
|
631
|
+
annotations=ToolAnnotations(
|
|
632
|
+
title="Create a New Branch",
|
|
633
|
+
readOnlyHint=False,
|
|
634
|
+
destructiveHint=False,
|
|
635
|
+
idempotentHint=False,
|
|
636
|
+
openWorldHint=True,
|
|
637
|
+
),
|
|
638
|
+
)
|
|
639
|
+
async def github_create_branch(params: CreateBranchInput) -> str:
|
|
640
|
+
"""Create a new branch from an existing branch, tag, or commit SHA.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
params (CreateBranchInput): Validated input containing:
|
|
644
|
+
- owner (str): Repository owner.
|
|
645
|
+
- repo (str): Repository name.
|
|
646
|
+
- branch (str): New branch name.
|
|
647
|
+
- base_ref (str): Existing branch/tag/SHA to branch from.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
str: Confirmation with the new branch name and the SHA it points to.
|
|
651
|
+
|
|
652
|
+
Error response: "Error <code>: <message>" string.
|
|
653
|
+
"""
|
|
654
|
+
try:
|
|
655
|
+
# Resolve the base ref to a commit SHA
|
|
656
|
+
ref_resp = await github_request(
|
|
657
|
+
"GET",
|
|
658
|
+
f"/repos/{params.owner}/{params.repo}/git/ref/heads/{params.base_ref}",
|
|
659
|
+
)
|
|
660
|
+
sha = ref_resp.json()["object"]["sha"]
|
|
661
|
+
except Exception:
|
|
662
|
+
# Might be a tag or direct SHA — try tags
|
|
663
|
+
try:
|
|
664
|
+
ref_resp = await github_request(
|
|
665
|
+
"GET",
|
|
666
|
+
f"/repos/{params.owner}/{params.repo}/git/ref/tags/{params.base_ref}",
|
|
667
|
+
)
|
|
668
|
+
sha = ref_resp.json()["object"]["sha"]
|
|
669
|
+
except Exception:
|
|
670
|
+
# Treat base_ref as a raw SHA
|
|
671
|
+
sha = params.base_ref
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
resp = await github_request(
|
|
675
|
+
"POST",
|
|
676
|
+
f"/repos/{params.owner}/{params.repo}/git/refs",
|
|
677
|
+
json={"ref": f"refs/heads/{params.branch}", "sha": sha},
|
|
678
|
+
)
|
|
679
|
+
data = resp.json()
|
|
680
|
+
created_sha = data.get("object", {}).get("sha", sha)[:7]
|
|
681
|
+
return (
|
|
682
|
+
f"Branch `{params.branch}` created in {params.owner}/{params.repo} "
|
|
683
|
+
f"from `{params.base_ref}` (SHA: {created_sha})."
|
|
684
|
+
)
|
|
685
|
+
except Exception as e:
|
|
686
|
+
return handle_github_error(e)
|