github-pro-mcp 0.0.1 → 0.0.2

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.
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  [![npm version](https://img.shields.io/npm/v/github-pro-mcp)](https://www.npmjs.com/package/github-pro-mcp)
4
4
  [![Docker image](https://img.shields.io/docker/v/anantanandgupta/github-pro-mcp)](https://hub.docker.com/r/anantanandgupta/github-pro-mcp)
5
5
 
6
- An MCP (Model Context Protocol) server providing comprehensive GitHub API access via hybrid REST + GraphQL — **32 tools across 7 domains**.
6
+ An MCP (Model Context Protocol) server providing comprehensive GitHub API access via hybrid REST + GraphQL — **34 tools across 8 domains**.
7
7
 
8
8
  ## Features
9
9
 
10
- - **32 Tools** — issues, pull requests, repositories, projects v2, labels, organizations, file operations
10
+ - **34 Tools** — issues, pull requests, repositories, projects v2, labels, organizations, file operations, server info
11
11
  - **Hybrid API** — REST for CRUD/search, GraphQL for cursor pagination and composite queries
12
12
  - **Resilient by Default** — automatic retry with exponential backoff + jitter, circuit breaker for API outages, proactive rate limit management
13
13
  - **Structured Logging** — multi-level (error/warn/info/debug) stderr output
@@ -69,6 +69,10 @@ docker run -i --rm \
69
69
  | `GITHUB_RETRY_BACKOFF_MS` | `1000` | Initial retry backoff (ms) |
70
70
  | `GITHUB_CIRCUIT_BREAKER_THRESHOLD` | `5` | Failures before circuit opens |
71
71
  | `GITHUB_CIRCUIT_BREAKER_RESET_MS` | `30000` | Circuit recovery timeout (ms) |
72
+ | `GITHUB_RETRY_BACKOFF_FACTOR` | `2` | Exponential backoff multiplier |
73
+ | `GITHUB_OWNER_TYPE_CACHE_MS` | `300000` | Cache TTL for owner type lookups (5 min) |
74
+ | `GITHUB_THROTTLE_ON_RATE_LIMIT` | `retry` | Primary rate limit strategy: `retry` or `queue` |
75
+ | `GITHUB_THROTTLE_ON_SECONDARY_RATE_LIMIT` | `retry` | Secondary rate limit strategy: `retry` or `queue` |
72
76
  | `GITHUB_LOG_LEVEL` | `error` | Log level: `error`, `warn`, `info`, `debug` |
73
77
 
74
78
  ## Usage with opencode
@@ -109,14 +113,15 @@ For Docker-based usage:
109
113
 
110
114
  | Domain | Tools | Count |
111
115
  |--------|-------|-------|
112
- | **User & Repos** | `auth_get_current_login`, `file_get_content`, `profile_get`, `owner_list_repository`, `file_create_update`, `code_search`, `repository_search` | 7 |
116
+ | **User & Repos** | `auth_get_current_login`, `file_get_content`, `profile_get`, `owner_list_repository`, `file_create_update`, `file_delete`, `code_search`, `repository_search` | 8 |
113
117
  | **Issues** | `issue_read`, `issue_list`, `issue_search`, `issue_add_comment`, `sub_issue_write`, `issue_create`, `issue_close`, `issue_reopen`, `issue_update`, `issue_add_label`, `issue_remove_label` | 11 |
114
118
  | **Issue Fields** | `owner_list_issue_field`, `issue_get_field_value`, `issue_set_field_value` | 3 |
115
119
  | **Pull Requests** | `pull_request_read`, `pull_request_create`, `pull_request_merge` | 3 |
116
120
  | **Organizations** | `organization_search`, `owner_list_issue_type`, `owner_list_organization` | 3 |
117
121
  | **Projects v2** | `owner_list_project`, `projects_get`, `projects_write` | 3 |
118
122
  | **Labels** | `label_list`, `label_write` | 2 |
119
- | **Total** | | **32** |
123
+ | **Server Info** | `server_info` | 1 |
124
+ | **Total** | | **34** |
120
125
 
121
126
  ## FAQ / Troubleshooting
122
127
 
package/dist/client.d.ts CHANGED
@@ -4,8 +4,9 @@ export type OctokitWithRest = Octokit & {
4
4
  rest: Api["rest"];
5
5
  };
6
6
  /**
7
- * Creates an authenticated Octokit client with all plugins configured.
7
+ * Creates a lazy singleton Octokit client with all plugins configured.
8
8
  *
9
+ * The singleton preserves throttling queue state across calls.
9
10
  * The returned client wraps all REST requests through:
10
11
  * circuit breaker → retry → throttling → GitHub API
11
12
  *
package/dist/client.js CHANGED
@@ -11,9 +11,11 @@ const MyOctokit = Octokit
11
11
  .plugin(restEndpointMethods)
12
12
  .plugin(paginateGraphQL)
13
13
  .plugin(throttling);
14
+ let cachedClient = null;
14
15
  /**
15
- * Creates an authenticated Octokit client with all plugins configured.
16
+ * Creates a lazy singleton Octokit client with all plugins configured.
16
17
  *
18
+ * The singleton preserves throttling queue state across calls.
17
19
  * The returned client wraps all REST requests through:
18
20
  * circuit breaker → retry → throttling → GitHub API
19
21
  *
@@ -21,6 +23,8 @@ const MyOctokit = Octokit
21
23
  * via `updateRateLimits()`.
22
24
  */
23
25
  export function createClient() {
26
+ if (cachedClient)
27
+ return cachedClient;
24
28
  const token = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
25
29
  if (!token) {
26
30
  throw new Error("GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set. " +
@@ -53,6 +57,7 @@ export function createClient() {
53
57
  const label = `REST ${options.method} ${options.url}`;
54
58
  return withCircuitBreaker(() => withRetry(() => Promise.resolve(request(options)), label), "github-rest");
55
59
  });
60
+ cachedClient = octokit;
56
61
  return octokit;
57
62
  }
58
63
  /**
package/dist/index.js CHANGED
@@ -1,16 +1,33 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { registerAuthGetCurrentLogin, registerFileGetContent, registerProfileGet, registerOwnerListRepository, registerFileCreateUpdate, registerCodeSearch, registerRepositorySearch, } from "./tools/repos.js";
5
+ const require = createRequire(import.meta.url);
6
+ const pkg = require("../package.json");
7
+ import { registerAuthGetCurrentLogin, registerFileGetContent, registerProfileGet, registerOwnerListRepository, registerFileCreateUpdate, registerFileDelete, registerCodeSearch, registerRepositorySearch, } from "./tools/repos.js";
5
8
  import { registerOrganizationSearch, registerOwnerListIssueType, registerOwnerListOrganization, } from "./tools/organizations.js";
6
9
  import { registerPullRequestRead, registerPullRequestCreate, registerPullRequestMerge, } from "./tools/pull_requests.js";
7
10
  import { registerIssueRead, registerIssueList, registerIssueSearch, registerIssueAddComment, registerSubIssueWrite, registerIssueCreate, registerIssueClose, registerIssueReopen, registerIssueUpdate, registerIssueAddLabel, registerIssueRemoveLabel, } from "./tools/issues.js";
8
11
  import { registerOwnerListIssueField, registerIssueGetFieldValue, registerIssueSetFieldValue, } from "./tools/issue_fields.js";
9
12
  import { registerLabelList, registerLabelWrite, } from "./tools/labels.js";
10
13
  import { registerOwnerListProject, registerProjectsGet, registerProjectsWrite, } from "./tools/projects.js";
11
- const server = new McpServer({ name: "github-pro-mcp", version: "2.0.1" }, { capabilities: { tools: {} } });
14
+ import { registerServerInfo } from "./tools/server.js";
15
+ const server = new McpServer({ name: pkg.name, version: pkg.version }, { capabilities: { tools: {} } });
16
+ const serverMeta = {
17
+ name: pkg.name,
18
+ version: pkg.version,
19
+ description: pkg.description,
20
+ author: pkg.author,
21
+ license: pkg.license,
22
+ repository: pkg.repository?.url ?? "",
23
+ homepage: pkg.homepage ?? "",
24
+ bug_tracker: pkg.bugs?.url ?? "",
25
+ bin: pkg.bin ?? {},
26
+ tool_count: 34,
27
+ };
12
28
  registerAuthGetCurrentLogin(server);
13
29
  registerFileGetContent(server);
30
+ registerFileDelete(server);
14
31
  registerProfileGet(server);
15
32
  registerOwnerListRepository(server);
16
33
  registerFileCreateUpdate(server);
@@ -41,6 +58,7 @@ registerLabelWrite(server);
41
58
  registerOwnerListProject(server);
42
59
  registerProjectsGet(server);
43
60
  registerProjectsWrite(server);
61
+ registerServerInfo(server, serverMeta);
44
62
  async function main() {
45
63
  const transport = new StdioServerTransport();
46
64
  await server.connect(transport);
@@ -64,13 +64,17 @@ export function registerIssueSetFieldValue(server) {
64
64
  if (pinErr)
65
65
  return pinErr;
66
66
  const octokit = createClient();
67
+ const nameFields = fields.filter((f) => !f.field_id && f.field_name);
68
+ let fieldDefs = null;
69
+ if (nameFields.length > 0) {
70
+ const { data } = await octokit.request('GET /orgs/{owner}/issue-fields', { owner });
71
+ fieldDefs = data;
72
+ }
67
73
  const resolvedFields = [];
68
74
  for (const f of fields) {
69
75
  let fieldId = f.field_id;
70
76
  if (!fieldId && f.field_name) {
71
- const fieldName = f.field_name;
72
- const { data: fieldDefs } = await octokit.request('GET /orgs/{owner}/issue-fields', { owner });
73
- const matching = fieldDefs.find((fd) => fd.name.toLowerCase() === fieldName.toLowerCase());
77
+ const matching = fieldDefs.find((fd) => fd.name.toLowerCase() === f.field_name.toLowerCase());
74
78
  if (!matching) {
75
79
  return {
76
80
  content: [{ type: "text", text: `Field "${f.field_name}" not found. Available fields: ${fieldDefs.map((fd) => fd.name).join(", ")}` }],
@@ -1,7 +1,7 @@
1
1
  import * as z from "zod/v4";
2
2
  import { createClient, gql } from "../client.js";
3
3
  import { handleGitHubError } from "../utils/errors.js";
4
- import { validatePinnedOwnerRepo } from "../utils/validation.js";
4
+ import { validatePinnedOwnerRepo, requireIssueOpen } from "../utils/validation.js";
5
5
  import { OwnerRepoSchema, PaginationSchema, IssueNumberSchema, IncludeFlagsSchema } from "../utils/types.js";
6
6
  export function registerIssueRead(server) {
7
7
  server.registerTool("issue_read", {
@@ -53,17 +53,17 @@ export function registerIssueRead(server) {
53
53
  if (include_labels) {
54
54
  const labelLimit = max_labels ?? 20;
55
55
  const query = `
56
- query($owner: String!, $repo: String!, $number: Int!) {
56
+ query($owner: String!, $repo: String!, $number: Int!, $labelLimit: Int!) {
57
57
  repository(owner: $owner, name: $repo) {
58
58
  issue(number: $number) {
59
- labels(first: ${labelLimit}) {
59
+ labels(first: $labelLimit) {
60
60
  nodes { name color description }
61
61
  }
62
62
  }
63
63
  }
64
64
  }
65
65
  `;
66
- const labelData = await gql(query, { owner, repo, number: issue_number });
66
+ const labelData = await gql(query, { owner, repo, number: issue_number, labelLimit });
67
67
  result.labels_graphql = labelData.repository?.issue?.labels?.nodes ?? [];
68
68
  }
69
69
  if (include_fields) {
@@ -72,17 +72,17 @@ export function registerIssueRead(server) {
72
72
  if (include_sub_issues) {
73
73
  const subLimit = max_sub_issues ?? 20;
74
74
  const query = `
75
- query($owner: String!, $repo: String!, $number: Int!) {
75
+ query($owner: String!, $repo: String!, $number: Int!, $subLimit: Int!) {
76
76
  repository(owner: $owner, name: $repo) {
77
77
  issue(number: $number) {
78
- subIssues(first: ${subLimit}) {
78
+ subIssues(first: $subLimit) {
79
79
  nodes { number title state }
80
80
  }
81
81
  }
82
82
  }
83
83
  }
84
84
  `;
85
- const subData = await gql(query, { owner, repo, number: issue_number });
85
+ const subData = await gql(query, { owner, repo, number: issue_number, subLimit });
86
86
  result.sub_issues = subData.repository?.issue?.subIssues?.nodes ?? [];
87
87
  }
88
88
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -96,7 +96,7 @@ export function registerIssueList(server) {
96
96
  server.registerTool("issue_list", {
97
97
  description: "List issues in a repository. Optionally include org-level field values (Priority, Effort) and/or project board field values (Status, Application). Supports field-based filtering with filter_fields (AND logic).",
98
98
  inputSchema: OwnerRepoSchema.extend({
99
- state: z.enum(["open", "closed"]).optional().describe("Filter by issue state"),
99
+ state: z.enum(["open", "closed", "all"]).optional().describe("Filter by issue state"),
100
100
  labels: z.string().optional().describe("Comma-separated label names to filter by"),
101
101
  per_page: PaginationSchema.shape.per_page,
102
102
  page: PaginationSchema.shape.page,
@@ -129,18 +129,18 @@ export function registerIssueList(server) {
129
129
  fieldIdToName = null;
130
130
  }
131
131
  }
132
- const restParams = { owner, repo, per_page, page };
132
+ const restParams = { owner, repo, per_page, page, sort: "updated", direction: "desc" };
133
133
  if (state)
134
134
  restParams.state = state;
135
135
  if (labels)
136
136
  restParams.labels = labels;
137
137
  const { data: restIssues } = await octokit.rest.issues.listForRepo(restParams);
138
138
  if (!restIssues.length) {
139
- return { content: [{ type: "text", text: JSON.stringify({ total: 0, issues: [] }, null, 2) }] };
139
+ return { content: [{ type: "text", text: JSON.stringify({ total: 0, page, per_page, issues: [] }, null, 2) }] };
140
140
  }
141
141
  let projectMap = null;
142
142
  if (needProjectFields) {
143
- const gqlStates = state ? [state.toUpperCase()] : null;
143
+ const gqlStates = state && state !== "all" ? [state.toUpperCase()] : null;
144
144
  const gqlLabels = labels ? labels.split(",").map((l) => l.trim()) : null;
145
145
  const gqlQuery = `
146
146
  query($owner: String!, $repo: String!, $first: Int!, $states: [IssueState!], $labels: [String!]) {
@@ -169,10 +169,11 @@ export function registerIssueList(server) {
169
169
  }
170
170
  `;
171
171
  const gqlResult = await gql(gqlQuery, {
172
- owner, repo, first: per_page, states: gqlStates, labels: gqlLabels,
172
+ owner, repo, first: page * per_page, states: gqlStates, labels: gqlLabels,
173
173
  });
174
174
  projectMap = new Map();
175
- for (const node of gqlResult.repository?.issues?.nodes ?? []) {
175
+ const skipItems = (page - 1) * per_page;
176
+ for (const node of (gqlResult.repository?.issues?.nodes ?? []).slice(skipItems)) {
176
177
  projectMap.set(node.number, (node.projectItems?.nodes ?? []));
177
178
  }
178
179
  }
@@ -301,28 +302,32 @@ export function registerIssueSearch(server) {
301
302
  server.registerTool("issue_search", {
302
303
  description: "Search for issues in GitHub repositories using GitHub's search syntax.",
303
304
  inputSchema: z.object({
304
- query: z.string().min(1).describe("Search query using GitHub issues search syntax, e.g., 'label:bug repo:owner/repo'"),
305
+ query: z.string().min(1).max(1000).describe("Search query using GitHub issues search syntax, e.g., 'label:bug repo:owner/repo'"),
305
306
  page: PaginationSchema.shape.page,
306
307
  per_page: PaginationSchema.shape.per_page,
307
308
  }),
308
309
  }, async ({ query, page, per_page }) => {
309
310
  try {
310
311
  const octokit = createClient();
311
- const { data } = await octokit.rest.search.issuesAndPullRequests({ q: query, page, per_page });
312
+ const searchQuery = query.includes("is:issue") ? query : `${query} is:issue`;
313
+ const { data } = await octokit.rest.search.issuesAndPullRequests({ q: searchQuery, page, per_page });
314
+ const items = data.items
315
+ .filter((i) => !i.pull_request)
316
+ .map((i) => ({
317
+ number: i.number,
318
+ title: i.title,
319
+ state: i.state,
320
+ html_url: i.html_url,
321
+ repository: i.repository?.full_name ?? null,
322
+ created_at: i.created_at,
323
+ updated_at: i.updated_at,
324
+ user: i.user?.login,
325
+ labels: i.labels?.map((l) => (typeof l === "string" ? l : l.name)) ?? [],
326
+ }));
312
327
  return {
313
328
  content: [{ type: "text", text: JSON.stringify({
314
329
  total_count: data.total_count,
315
- items: data.items.map((i) => ({
316
- number: i.number,
317
- title: i.title,
318
- state: i.state,
319
- html_url: i.html_url,
320
- repository: i.repository?.full_name ?? null,
321
- created_at: i.created_at,
322
- updated_at: i.updated_at,
323
- user: i.user?.login,
324
- labels: i.labels?.map((l) => (typeof l === "string" ? l : l.name)) ?? [],
325
- })),
330
+ items,
326
331
  }, null, 2) }],
327
332
  };
328
333
  }
@@ -474,9 +479,15 @@ export function registerIssueClose(server) {
474
479
  const octokit = createClient();
475
480
  const { data } = await octokit.rest.issues.update({ owner, repo, issue_number, state: "closed" });
476
481
  let commentPosted = false;
482
+ let commentError;
477
483
  if (comment) {
478
- await octokit.rest.issues.createComment({ owner, repo, issue_number, body: comment });
479
- commentPosted = true;
484
+ try {
485
+ await octokit.rest.issues.createComment({ owner, repo, issue_number, body: comment });
486
+ commentPosted = true;
487
+ }
488
+ catch (e) {
489
+ commentError = e instanceof Error ? e.message : String(e);
490
+ }
480
491
  }
481
492
  return {
482
493
  content: [{ type: "text", text: JSON.stringify({
@@ -484,6 +495,7 @@ export function registerIssueClose(server) {
484
495
  state: data.state,
485
496
  closed_at: data.closed_at,
486
497
  comment_posted: commentPosted,
498
+ comment_error: commentError,
487
499
  }, null, 2) }],
488
500
  };
489
501
  }
@@ -525,12 +537,19 @@ export function registerIssueUpdate(server) {
525
537
  title: z.string().optional().describe("New title for the issue"),
526
538
  body: z.string().optional().describe("New body content for the issue"),
527
539
  state: z.enum(["open", "closed"]).optional().describe("New state for the issue"),
540
+ }).refine((data) => data.title !== undefined || data.body !== undefined || data.state !== undefined, {
541
+ message: "At least one of title, body, or state must be provided.",
528
542
  }),
529
543
  }, async ({ owner, repo, issue_number, title, body, state }) => {
530
544
  try {
531
545
  const pinErr = validatePinnedOwnerRepo(owner, repo);
532
546
  if (pinErr)
533
547
  return pinErr;
548
+ if (title !== undefined || body !== undefined) {
549
+ const closedErr = await requireIssueOpen(owner, repo, issue_number);
550
+ if (closedErr)
551
+ return closedErr;
552
+ }
534
553
  const octokit = createClient();
535
554
  const { data } = await octokit.rest.issues.update({ owner, repo, issue_number, title, body, state });
536
555
  return {
@@ -588,15 +607,23 @@ export function registerIssueRemoveLabel(server) {
588
607
  if (pinErr)
589
608
  return pinErr;
590
609
  const octokit = createClient();
591
- const results = [];
610
+ const removed = [];
611
+ const failed = [];
592
612
  for (const label of labels) {
593
- await octokit.rest.issues.removeLabel({ owner, repo, issue_number, name: label });
594
- results.push(label);
613
+ try {
614
+ await octokit.rest.issues.removeLabel({ owner, repo, issue_number, name: label });
615
+ removed.push(label);
616
+ }
617
+ catch (e) {
618
+ failed.push({ label, error: e instanceof Error ? e.message : String(e) });
619
+ }
595
620
  }
596
621
  return {
597
622
  content: [{ type: "text", text: JSON.stringify({
598
- success: true,
599
- labels_removed: results,
623
+ partial: removed.length > 0 && failed.length > 0,
624
+ success: removed.length > 0,
625
+ labels_removed: removed,
626
+ labels_failed: failed.length > 0 ? failed : undefined,
600
627
  }, null, 2) }],
601
628
  };
602
629
  }
@@ -7,7 +7,7 @@ export function registerOrganizationSearch(server) {
7
7
  server.registerTool("organization_search", {
8
8
  description: "Search for organizations on GitHub by query. Supports qualifiers like 'location:...', 'repos:>...'.",
9
9
  inputSchema: z.object({
10
- query: z.string().min(1).describe("Search query, e.g., 'microsoft' or 'location:seattle repos:>5'"),
10
+ query: z.string().min(1).max(1000).describe("Search query, e.g., 'microsoft' or 'location:seattle repos:>5'"),
11
11
  page: PaginationSchema.shape.page,
12
12
  per_page: PaginationSchema.shape.per_page,
13
13
  }),
@@ -17,114 +17,62 @@ export function registerOwnerListProject(server) {
17
17
  const pinErr = validatePinnedOwnerRepo(owner);
18
18
  if (pinErr)
19
19
  return pinErr;
20
- if (owner_type === "org") {
21
- const gqlQuery = `
22
- query($owner: String!, $first: Int!, $after: String, $searchStr: String) {
23
- organization(login: $owner) {
24
- projectsV2(first: $first, after: $after, query: $searchStr) {
25
- nodes {
26
- id
27
- number
28
- title
29
- shortDescription
30
- url
31
- closed
32
- createdAt
33
- updatedAt
34
- }
35
- pageInfo {
36
- hasNextPage
37
- endCursor
38
- }
20
+ const rootType = owner_type === "org" ? "organization" : "user";
21
+ const gqlQuery = `
22
+ query($owner: String!, $first: Int!, $after: String, $searchStr: String) {
23
+ ${rootType}(login: $owner) {
24
+ projectsV2(first: $first, after: $after, query: $searchStr) {
25
+ nodes {
26
+ id
27
+ number
28
+ title
29
+ shortDescription
30
+ url
31
+ closed
32
+ createdAt
33
+ updatedAt
39
34
  }
40
- }
41
- }
42
- `;
43
- const data = await gql(gqlQuery, {
44
- owner, first, after: after ?? null, searchStr: state ? `is:${state}` : null,
45
- });
46
- const org = data.organization;
47
- if (!org) {
48
- return {
49
- content: [{ type: "text", text: `Organization "${owner}" not found.` }],
50
- isError: true,
51
- };
52
- }
53
- const projects = org.projectsV2?.nodes ?? [];
54
- const pageInfo = org.projectsV2?.pageInfo;
55
- return {
56
- content: [{ type: "text", text: JSON.stringify({
57
- projects: projects.map((p) => ({
58
- id: p.id,
59
- number: p.number,
60
- title: p.title,
61
- description: p.shortDescription,
62
- url: p.url,
63
- closed: p.closed,
64
- created_at: p.createdAt,
65
- updated_at: p.updatedAt,
66
- })),
67
- page_info: {
68
- has_next_page: pageInfo?.hasNextPage ?? false,
69
- end_cursor: pageInfo?.endCursor ?? null,
70
- },
71
- }, null, 2) }],
72
- };
73
- }
74
- else {
75
- const gqlQuery = `
76
- query($owner: String!, $first: Int!, $after: String, $searchStr: String) {
77
- user(login: $owner) {
78
- projectsV2(first: $first, after: $after, query: $searchStr) {
79
- nodes {
80
- id
81
- number
82
- title
83
- shortDescription
84
- url
85
- closed
86
- createdAt
87
- updatedAt
88
- }
89
- pageInfo {
90
- hasNextPage
91
- endCursor
92
- }
35
+ pageInfo {
36
+ hasNextPage
37
+ endCursor
93
38
  }
94
39
  }
95
40
  }
96
- `;
97
- const data = await gql(gqlQuery, {
98
- owner, first, after: after ?? null, searchStr: state ? `is:${state}` : null,
99
- });
100
- const user = data.user;
101
- if (!user) {
102
- return {
103
- content: [{ type: "text", text: `User "${owner}" not found.` }],
104
- isError: true,
105
- };
106
- }
107
- const projects = user.projectsV2?.nodes ?? [];
108
- const pageInfo = user.projectsV2?.pageInfo;
41
+ }
42
+ `;
43
+ const data = await gql(gqlQuery, {
44
+ owner, first, after: after ?? null, searchStr: state ? `is:${state}` : null,
45
+ });
46
+ const root = owner_type === "org"
47
+ ? data.organization
48
+ : data.user;
49
+ if (!root) {
50
+ const label = owner_type === "org" ? "Organization" : "User";
109
51
  return {
110
- content: [{ type: "text", text: JSON.stringify({
111
- projects: projects.map((p) => ({
112
- id: p.id,
113
- number: p.number,
114
- title: p.title,
115
- description: p.shortDescription,
116
- url: p.url,
117
- closed: p.closed,
118
- created_at: p.createdAt,
119
- updated_at: p.updatedAt,
120
- })),
121
- page_info: {
122
- has_next_page: pageInfo?.hasNextPage ?? false,
123
- end_cursor: pageInfo?.endCursor ?? null,
124
- },
125
- }, null, 2) }],
52
+ content: [{ type: "text", text: `${label} "${owner}" not found.` }],
53
+ isError: true,
126
54
  };
127
55
  }
56
+ const projects = root.projectsV2?.nodes ?? [];
57
+ const pageInfo = root.projectsV2?.pageInfo;
58
+ return {
59
+ content: [{ type: "text", text: JSON.stringify({
60
+ projects: projects.map((p) => ({
61
+ id: p.id,
62
+ number: p.number,
63
+ title: p.title,
64
+ description: p.shortDescription,
65
+ url: p.url,
66
+ closed: p.closed,
67
+ created_at: p.createdAt,
68
+ updated_at: p.updatedAt,
69
+ })),
70
+ page_info: {
71
+ has_next_page: pageInfo?.hasNextPage ?? false,
72
+ end_cursor: pageInfo?.endCursor ?? null,
73
+ },
74
+ }, null, 2) }],
75
+ };
128
76
  }
129
77
  catch (err) {
130
78
  return handleGitHubError(err);
@@ -177,69 +125,17 @@ export function registerProjectsGet(server) {
177
125
  const itemsLimit = max_items ?? 50;
178
126
  const statusUpdatesLimit = max_status_updates ?? 20;
179
127
  const fieldValueLimit = max_field_values ?? 20;
180
- const fieldsFragment = include_fields ? `
181
- fields(first: ${fieldsLimit}) {
182
- nodes {
183
- ... on ProjectV2Field {
184
- id name dataType
185
- }
186
- ... on ProjectV2SingleSelectField {
187
- id name dataType
188
- options { id name color }
189
- }
190
- ... on ProjectV2IterationField {
191
- id name dataType
192
- configuration { iterations { id title startDate duration } }
193
- }
194
- }
195
- }
196
- ` : "";
197
- const itemsFragment = include_items ? `
198
- items(first: ${itemsLimit}) {
199
- nodes {
200
- id
201
- type
202
- fieldValues(first: ${fieldValueLimit}) {
203
- nodes {
204
- ... on ProjectV2ItemFieldSingleSelectValue {
205
- name optionId field { ... on ProjectV2SingleSelectField { id name } }
206
- }
207
- ... on ProjectV2ItemFieldTextValue {
208
- text field { ... on ProjectV2Field { id name } }
209
- }
210
- ... on ProjectV2ItemFieldNumberValue {
211
- number field { ... on ProjectV2Field { id name } }
212
- }
213
- ... on ProjectV2ItemFieldDateValue {
214
- date field { ... on ProjectV2Field { id name } }
215
- }
216
- ... on ProjectV2ItemFieldIterationValue {
217
- title field { ... on ProjectV2IterationField { id name } }
218
- }
219
- }
220
- }
221
- content {
222
- ... on Issue { number title state url assignees(first: 5) { nodes { login } } }
223
- ... on PullRequest { number title state url }
224
- }
225
- }
226
- }
227
- ` : "";
228
- const statusUpdatesFragment = include_status_updates ? `
229
- statusUpdates(first: ${statusUpdatesLimit}) {
230
- nodes {
231
- id
232
- body
233
- status
234
- createdAt
235
- updatedAt
236
- startDate
237
- targetDate
238
- }
239
- }
240
- ` : "";
241
128
  const query = `
242
- query($id: ID!) {
129
+ query(
130
+ $id: ID!,
131
+ $includeFields: Boolean!,
132
+ $includeItems: Boolean!,
133
+ $includeStatusUpdates: Boolean!,
134
+ $fieldsLimit: Int!,
135
+ $itemsLimit: Int!,
136
+ $statusUpdatesLimit: Int!,
137
+ $fieldValueLimit: Int!
138
+ ) {
243
139
  node(id: $id) {
244
140
  ... on ProjectV2 {
245
141
  id
@@ -251,14 +147,75 @@ export function registerProjectsGet(server) {
251
147
  closedAt
252
148
  createdAt
253
149
  updatedAt
254
- ${fieldsFragment}
255
- ${itemsFragment}
256
- ${statusUpdatesFragment}
150
+ fields(first: $fieldsLimit) @include(if: $includeFields) {
151
+ nodes {
152
+ ... on ProjectV2Field {
153
+ id name dataType
154
+ }
155
+ ... on ProjectV2SingleSelectField {
156
+ id name dataType
157
+ options { id name color }
158
+ }
159
+ ... on ProjectV2IterationField {
160
+ id name dataType
161
+ configuration { iterations { id title startDate duration } }
162
+ }
163
+ }
164
+ }
165
+ items(first: $itemsLimit) @include(if: $includeItems) {
166
+ nodes {
167
+ id
168
+ type
169
+ fieldValues(first: $fieldValueLimit) {
170
+ nodes {
171
+ ... on ProjectV2ItemFieldSingleSelectValue {
172
+ name optionId field { ... on ProjectV2SingleSelectField { id name } }
173
+ }
174
+ ... on ProjectV2ItemFieldTextValue {
175
+ text field { ... on ProjectV2Field { id name } }
176
+ }
177
+ ... on ProjectV2ItemFieldNumberValue {
178
+ number field { ... on ProjectV2Field { id name } }
179
+ }
180
+ ... on ProjectV2ItemFieldDateValue {
181
+ date field { ... on ProjectV2Field { id name } }
182
+ }
183
+ ... on ProjectV2ItemFieldIterationValue {
184
+ title field { ... on ProjectV2IterationField { id name } }
185
+ }
186
+ }
187
+ }
188
+ content {
189
+ ... on Issue { number title state url assignees(first: 5) { nodes { login } } }
190
+ ... on PullRequest { number title state url }
191
+ }
192
+ }
193
+ }
194
+ statusUpdates(first: $statusUpdatesLimit) @include(if: $includeStatusUpdates) {
195
+ nodes {
196
+ id
197
+ body
198
+ status
199
+ createdAt
200
+ updatedAt
201
+ startDate
202
+ targetDate
203
+ }
204
+ }
257
205
  }
258
206
  }
259
207
  }
260
208
  `;
261
- const data = await gql(query, { id: project_id });
209
+ const data = await gql(query, {
210
+ id: project_id,
211
+ includeFields: !!include_fields,
212
+ includeItems: !!include_items,
213
+ includeStatusUpdates: !!include_status_updates,
214
+ fieldsLimit,
215
+ itemsLimit,
216
+ statusUpdatesLimit,
217
+ fieldValueLimit,
218
+ });
262
219
  const project = data.node;
263
220
  if (!project) {
264
221
  return {
@@ -307,10 +264,10 @@ export function registerProjectsWrite(server) {
307
264
  field_values: z.array(z.object({
308
265
  field_id: z.string().describe("GraphQL node ID of the project field"),
309
266
  value: z.union([z.string(), z.number(), z.null()]).describe("Field value"),
310
- value_type: z.enum(["text", "number", "date", "single_select", "iteration"]).describe("Type of the field value: single_select (option ID as string), date (ISO 8601), text, number, or iteration (iteration ID)"),
267
+ value_type: z.enum(["single_select", "text", "number", "date", "iteration", "title"]).describe("Type of the field value — use the field's dataType from projects_get / owner_list_project to determine the correct value_type"),
311
268
  })).optional().describe("Field values to set (update_item)"),
312
269
  body: z.string().optional().describe("Status update body text (create_status_update)"),
313
- status: z.enum(["IN_PROGRESS", "ON_TRACK", "BLOCKED", "AT_RISK", "OFF_TRACK", "COMPLETE"]).optional().describe("Status update status (create_status_update)"),
270
+ status: z.string().optional().describe("Status update status (create_status_update) — pass any value GitHub accepts"),
314
271
  start_date: z.string().optional().describe("Start date in ISO 8601 format (create_status_update)"),
315
272
  target_date: z.string().optional().describe("Target date in ISO 8601 format (create_status_update)"),
316
273
  owner: z.string().optional().describe("Required when using issue_number. Must match the server's configured owner if pinned."),
@@ -388,22 +345,31 @@ export function registerProjectsWrite(server) {
388
345
  let value;
389
346
  switch (fv.value_type) {
390
347
  case "single_select":
391
- value = { singleSelectOptionId: String(fv.value) };
348
+ value = fv.value === null ? { singleSelectOptionId: null } : { singleSelectOptionId: String(fv.value) };
392
349
  break;
393
350
  case "date":
394
- value = { date: fv.value };
351
+ value = fv.value === null ? { date: null } : { date: fv.value };
395
352
  break;
396
353
  case "text":
397
- value = { text: String(fv.value) };
354
+ value = fv.value === null ? { text: null } : { text: String(fv.value) };
398
355
  break;
399
356
  case "number":
400
- value = { number: Number(fv.value) };
357
+ value = fv.value === null ? { number: null } : { number: Number(fv.value) };
401
358
  break;
402
359
  case "iteration":
403
- value = { iterationId: String(fv.value) };
360
+ value = fv.value === null ? { iterationId: null } : { iterationId: String(fv.value) };
361
+ break;
362
+ case "title":
363
+ value = fv.value === null ? { title: null } : { title: String(fv.value) };
404
364
  break;
405
365
  default:
406
- value = fv.value === null ? { value: null } : { text: String(fv.value) };
366
+ if (fv.value === null) {
367
+ return {
368
+ content: [{ type: "text", text: `Cannot clear field — unknown value_type "${fv.value_type}". Provide a valid value_type or use a known type (single_select, text, number, date, iteration, title).` }],
369
+ isError: true,
370
+ };
371
+ }
372
+ value = { text: String(fv.value) };
407
373
  }
408
374
  const result = await gql(mutation, {
409
375
  projectId: project_id, itemId: item_id, fieldId: fv.field_id, value,
@@ -101,7 +101,7 @@ export function registerPullRequestMerge(server) {
101
101
  description: "Merge a pull request in a GitHub repository.",
102
102
  inputSchema: OwnerRepoSchema.extend({
103
103
  pull_number: PullNumberSchema,
104
- merge_method: z.enum(["merge", "squash", "rebase"]).optional().default("merge").describe("Merge method"),
104
+ merge_method: z.enum(["merge", "squash", "rebase"]).optional().default("merge").describe("Merge method — GitHub accepts: merge, squash, rebase"),
105
105
  commit_title: z.string().optional().describe("Title for merge commit"),
106
106
  commit_message: z.string().optional().describe("Extra detail for merge commit"),
107
107
  }),
@@ -5,4 +5,5 @@ export declare function registerProfileGet(server: McpServer): void;
5
5
  export declare function registerOwnerListRepository(server: McpServer): void;
6
6
  export declare function registerFileCreateUpdate(server: McpServer): void;
7
7
  export declare function registerCodeSearch(server: McpServer): void;
8
+ export declare function registerFileDelete(server: McpServer): void;
8
9
  export declare function registerRepositorySearch(server: McpServer): void;
@@ -100,7 +100,7 @@ export function registerOwnerListRepository(server) {
100
100
  inputSchema: z.object({
101
101
  owner: z.string().min(1).describe("Repository owner (user or organization)"),
102
102
  type: z.enum(["all", "owner", "member"]).optional().default("all").describe("Type of repositories to list (all, owner, member)"),
103
- sort: z.enum(["created", "updated", "pushed", "full_name"]).optional().default("created").describe("Sort field"),
103
+ sort: z.enum(["created", "updated", "pushed", "full_name", "name"]).optional().default("created").describe("Sort field"),
104
104
  direction: z.enum(["asc", "desc"]).optional().describe("Sort direction (asc or desc)"),
105
105
  page: PaginationSchema.shape.page,
106
106
  per_page: PaginationSchema.shape.per_page,
@@ -111,7 +111,7 @@ export function registerOwnerListRepository(server) {
111
111
  if (pinErr)
112
112
  return pinErr;
113
113
  const octokit = createClient();
114
- const { data } = await octokit.rest.repos.listForUser({ username: owner, type, sort, direction, page, per_page });
114
+ const { data } = await octokit.rest.repos.listForUser({ username: owner, type, sort: sort, direction, page, per_page });
115
115
  return {
116
116
  content: [{ type: "text", text: JSON.stringify(data.map((r) => formatRepo(r)), null, 2) }],
117
117
  };
@@ -163,7 +163,7 @@ export function registerCodeSearch(server) {
163
163
  server.registerTool("code_search", {
164
164
  description: "Search for code across GitHub repositories. Supports GitHub search qualifiers like 'repo:...', 'language:...', 'path:...'.",
165
165
  inputSchema: z.object({
166
- query: z.string().min(1).describe("Search query with GitHub qualifiers, e.g., 'function repo:owner/repo language:python'"),
166
+ query: z.string().min(1).max(1000).describe("Search query with GitHub qualifiers, e.g., 'function repo:owner/repo language:python'"),
167
167
  page: PaginationSchema.shape.page,
168
168
  per_page: PaginationSchema.shape.per_page,
169
169
  }),
@@ -188,11 +188,45 @@ export function registerCodeSearch(server) {
188
188
  }
189
189
  });
190
190
  }
191
+ export function registerFileDelete(server) {
192
+ server.registerTool("file_delete", {
193
+ description: "Delete a file from a GitHub repository. Requires the SHA of the file (obtain from file_get_content).",
194
+ inputSchema: OwnerRepoSchema.extend({
195
+ path: z.string().min(1).describe("Path to the file to delete"),
196
+ message: z.string().min(1).describe("Commit message"),
197
+ branch: z.string().min(1).describe("Branch to delete the file from"),
198
+ sha: z.string().min(1).describe("SHA of the file to delete (obtain from file_get_content or file_create_update)"),
199
+ }),
200
+ }, async ({ owner, repo, path, message, branch, sha }) => {
201
+ try {
202
+ const pinErr = validatePinnedOwnerRepo(owner, repo);
203
+ if (pinErr)
204
+ return pinErr;
205
+ const octokit = createClient();
206
+ const response = await octokit.rest.repos.deleteFile({
207
+ owner, repo, path, message, branch, sha,
208
+ });
209
+ return {
210
+ content: [{ type: "text", text: JSON.stringify({
211
+ commit: {
212
+ sha: response.data.commit.sha,
213
+ message: response.data.commit.message,
214
+ html_url: response.data.commit.html_url,
215
+ },
216
+ content: null,
217
+ }, null, 2) }],
218
+ };
219
+ }
220
+ catch (err) {
221
+ return handleGitHubError(err);
222
+ }
223
+ });
224
+ }
191
225
  export function registerRepositorySearch(server) {
192
226
  server.registerTool("repository_search", {
193
227
  description: "Search for GitHub repositories by query. Supports GitHub search qualifiers like 'org:...', 'language:...', 'stars:>100'.",
194
228
  inputSchema: z.object({
195
- query: z.string().min(1).describe("Search query with GitHub qualifiers, e.g., 'org:microsoft language:python'"),
229
+ query: z.string().min(1).max(1000).describe("Search query with GitHub qualifiers, e.g., 'org:microsoft language:python'"),
196
230
  page: PaginationSchema.shape.page,
197
231
  per_page: PaginationSchema.shape.per_page,
198
232
  }),
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export interface ServerMetadata {
3
+ name: string;
4
+ version: string;
5
+ description: string;
6
+ author: string;
7
+ license: string;
8
+ repository: string;
9
+ homepage: string;
10
+ bug_tracker: string;
11
+ bin: Record<string, string>;
12
+ tool_count: number;
13
+ }
14
+ export declare function registerServerInfo(server: McpServer, meta: ServerMetadata): void;
@@ -0,0 +1,37 @@
1
+ import * as z from "zod/v4";
2
+ export function registerServerInfo(server, meta) {
3
+ server.registerTool("server_info", {
4
+ description: "Get information about this MCP server, including version, support links, and runtime configuration.",
5
+ inputSchema: z.object({}),
6
+ }, async () => {
7
+ const runtime = {
8
+ node_version: process.version,
9
+ platform: process.platform,
10
+ pinned_owner: process.env.GITHUB_OWNER ?? null,
11
+ pinned_repo: process.env.GITHUB_REPO ?? null,
12
+ log_level: process.env.GITHUB_LOG_LEVEL ?? "error",
13
+ retry_max_attempts: parseInt(process.env.GITHUB_RETRY_MAX_ATTEMPTS ?? "3", 10),
14
+ circuit_breaker_threshold: parseInt(process.env.GITHUB_CIRCUIT_BREAKER_THRESHOLD ?? "5", 10),
15
+ };
16
+ return {
17
+ content: [{
18
+ type: "text",
19
+ text: JSON.stringify({
20
+ name: meta.name,
21
+ version: meta.version,
22
+ description: meta.description,
23
+ author: meta.author,
24
+ license: meta.license,
25
+ repository: meta.repository,
26
+ homepage: meta.homepage,
27
+ bug_tracker: meta.bug_tracker,
28
+ npm_package: "https://www.npmjs.com/package/github-pro-mcp",
29
+ docker_image: "https://hub.docker.com/r/anantanandgupta/github-pro-mcp",
30
+ bin: meta.bin,
31
+ tools_registered: meta.tool_count,
32
+ runtime,
33
+ }, null, 2),
34
+ }],
35
+ };
36
+ });
37
+ }
@@ -9,4 +9,5 @@ interface ErrorResponse {
9
9
  export declare function validatePinnedOwnerRepo(owner?: string, repo?: string): ErrorResponse | null;
10
10
  export declare function requireOrg(owner: string): Promise<ErrorResponse | null>;
11
11
  export declare function requireUser(owner: string): Promise<ErrorResponse | null>;
12
+ export declare function requireIssueOpen(owner: string, repo: string, issue_number: number): Promise<ErrorResponse | null>;
12
13
  export {};
@@ -73,3 +73,16 @@ export async function requireUser(owner) {
73
73
  }
74
74
  return null;
75
75
  }
76
+ export async function requireIssueOpen(owner, repo, issue_number) {
77
+ const octokit = createClient();
78
+ const { data } = await octokit.rest.issues.get({ owner, repo, issue_number });
79
+ if (data.state === "closed") {
80
+ return {
81
+ content: [{ type: "text", text: `Issue #${issue_number} in ${owner}/${repo} is closed. ` +
82
+ `Reopen it first before modifying the title or body.`
83
+ }],
84
+ isError: true,
85
+ };
86
+ }
87
+ return null;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-pro-mcp",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "MCP server providing GitHub API access with hybrid REST + GraphQL support",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -12,15 +12,26 @@
12
12
  "scripts": {
13
13
  "build": "tsc",
14
14
  "start": "node dist/index.js",
15
- "test": "node --test 'src/__tests__/**/*.test.mjs'",
15
+ "test": "node --test --test-global-setup='src/__tests__/setup.mjs' --test-concurrency=1 'src/__tests__/**/*.test.mjs'",
16
+ "test:unit": "node --test 'src/__tests__/{retry,circuit_breaker,rate_limit}.test.mjs'",
17
+ "test:repos": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/repos.test.mjs'",
18
+ "test:issues": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/issues.test.mjs'",
19
+ "test:issue_fields": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/issue_fields.test.mjs'",
20
+ "test:projects": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/projects.test.mjs'",
21
+ "test:orgs": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/organizations.test.mjs'",
22
+ "test:labels": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/labels.test.mjs'",
23
+ "test:prs": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/pull_requests.test.mjs'",
24
+ "test:write": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/write.test.mjs'",
25
+ "test:consistency": "node --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/consistency.test.mjs'",
16
26
  "test:docker": "node --test 'test/docker/**/*.test.mjs'",
17
27
  "test:npm": "node --test 'test/integration/**/*.test.mjs'",
18
28
  "test:all": "npm test && npm run test:docker && npm run test:npm",
19
- "test:coverage": "node --experimental-test-coverage --test 'src/__tests__/**/*.test.mjs'",
29
+ "test:coverage": "node --experimental-test-coverage --test --test-global-setup='src/__tests__/setup.mjs' 'src/__tests__/**/*.test.mjs'",
20
30
  "test:inspector": "npx @modelcontextprotocol/inspector node dist/index.js",
21
31
  "dev": "tsc --watch",
22
32
  "prepack": "npm run build",
23
33
  "docker:build": "make docker-build",
34
+ "docker:build:local": "make docker-build-local",
24
35
  "docker:push": "make docker-push",
25
36
  "publish:npm": "npm run build && npm test && npm publish",
26
37
  "publish:all": "npm run publish:npm && npm run docker:push"