diff --git a/model-context-protocol/github-mcp-server/.gitignore b/model-context-protocol/github-mcp-server/.gitignore new file mode 100644 index 00000000..cdf058f9 --- /dev/null +++ b/model-context-protocol/github-mcp-server/.gitignore @@ -0,0 +1,41 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Logs ### +*.log +logs/ + +### Environment ### +.env +.env.local diff --git a/model-context-protocol/github-mcp-server/README.md b/model-context-protocol/github-mcp-server/README.md new file mode 100644 index 00000000..e1f31450 --- /dev/null +++ b/model-context-protocol/github-mcp-server/README.md @@ -0,0 +1,273 @@ +# GitHub MCP Server — Java / Spring AI + +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0.3-6DB33F?style=flat&logo=springboot&logoColor=white)](https://spring.io/projects/spring-boot) +[![Spring AI](https://img.shields.io/badge/Spring%20AI-2.0.0--M3-6DB33F?style=flat&logo=spring&logoColor=white)](https://spring.io/projects/spring-ai) +[![Java](https://img.shields.io/badge/Java-21-ED8B00?style=flat&logo=openjdk&logoColor=white)](https://openjdk.org/) +[![Lombok](https://img.shields.io/badge/Lombok-red?style=flat&logo=lombok&logoColor=white)](https://projectlombok.org/) + +A production-ready MCP server that connects any MCP-compatible AI agent to the GitHub API. +Manage repositories, issues, pull requests, and search — all through natural language. + +> **Transport:** HTTP/SSE on port 8080, compatible with Cursor and Claude Desktop out of the box. + +--- + +## Why this server? + +| Capability | This server | GitHub REST API | +|---|---|---| +| Repository management | ✅ | ✅ | +| Issue & PR operations | ✅ | ✅ | +| Branch & commit control | ✅ | ✅ | +| Code & user search | ✅ | ✅ | +| Natural language interface | ✅ | ❌ | +| MCP protocol (SSE) | ✅ | ❌ | +| Java / Spring AI | ✅ | ❌ | +| GitHub Enterprise support | ✅ | ✅ | + +--- + +## Tools (38 total) + +| Category | Count | Tools | +|---|---|---| +| Repository | 11 | `create_repository`, `fork_repository`, `get_repository`, `list_commits`, `get_commit`, `get_file_contents`, `create_or_update_file`, `delete_file`, `create_branch`, `list_branches`, `merge_branch` | +| Issues | 11 | `create_issue`, `update_issue`, `add_issue_comment`, `list_issues`, `get_issue`, `close_issue`, `reopen_issue`, `assign_issue`, `unassign_issue`, `add_issue_labels`, `remove_issue_label` | +| Pull Requests | 10 | `create_pull_request`, `update_pull_request`, `list_pull_requests`, `get_pull_request`, `merge_pull_request`, `close_pull_request`, `reopen_pull_request`, `add_pull_request_comment`, `create_pull_request_review`, `submit_pull_request_review` | +| Search | 4 | `search_code`, `search_issues`, `search_repositories`, `search_users` | +| Users | 3 | `get_authenticated_user`, `get_user`, `list_user_repositories` | + +--- + +## Quick start + +```bash +# 1. Build +mvn clean package + +# 2. Set credentials +export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_... + +# 3. Run (SSE transport — port 8080) +java -jar target/github-mcp-server-1.0.0.jar + +# 4. Verify +curl http://localhost:8080/health + +# 5. Inspect all tools +npx @modelcontextprotocol/inspector http://localhost:8080/mcp +``` + +Get your token from [GitHub Settings](https://github.com/settings/tokens) → Developer settings → Personal access tokens. +For GitHub Enterprise, set `GITHUB_HOST` to your instance URL. + +--- + +## Architecture + +``` +MCP Client (Cursor / Claude Desktop / other) + │ HTTP/SSE transport (/mcp + /mcp/message) + ▼ +Tool class (@McpTool — thin delegation layer, validates required params) + ▼ +Service interface + impl (business logic, error mapping, pagination) + ▼ +GitHubRestClient (typed HTTP gateway, PAT auth, exception handling) + ▼ +GitHub REST API +``` + +The architecture is strictly layered: + +- `client/` — GitHub integration boundary (HTTP, Bearer-Auth, error handling) +- `service/` — domain logic (filtering, mapping, pagination) +- `tools/` — MCP-facing surface (descriptions, param validation, delegation) +- Spring Boot — runtime and transport wrapper only + +Every tool returns a consistent `ApiResponse` envelope: + +```json +{ "success": true, "data": { ... } } +{ "success": false, "errorCode": "REPO_NOT_FOUND", "errorMessage": "..." } +``` + +--- + +## Configuration + +| Property | Env var | Required | Default | Description | +|---|---|---|---|---| +| — | `GITHUB_PERSONAL_ACCESS_TOKEN` | ✅ | — | GitHub Personal Access Token | +| — | `GITHUB_HOST` | ❌ | `github.com` | GitHub hostname (for GitHub Enterprise) | +| — | `GITHUB_READ_ONLY` | ❌ | `false` | Restrict to read-only operations | +| — | `GITHUB_TOOLSETS` | ❌ | all | Comma-separated list of toolsets to enable | +| — | `GITHUB_TOOLS` | ❌ | all | Comma-separated list of specific tools to enable | +| — | `GITHUB_EXCLUDE_TOOLS` | ❌ | none | Comma-separated list of tools to exclude | + +### Creating a GitHub Personal Access Token + +1. Go to **GitHub Settings → Developer settings → Personal access tokens** +2. Click **"Generate new token (classic)"** +3. Select the following scopes: + - `repo` — Full control of private repositories + - `workflow` — Update GitHub Action workflows + - `read:org` — Read org and team membership + - `gist` — Create gists + - `notifications` — Access notifications + - `read:user` — Read user profile data +4. Generate and copy the token + +--- + +## Client config + +### Cursor (`.cursor/mcp.json`) + +```json +{ + "mcpServers": { + "github": { + "url": "http://localhost:8080/mcp" + } + } +} +``` + +### Claude Desktop (`claude_desktop_config.json`) + +```json +{ + "mcpServers": { + "github": { + "url": "http://localhost:8080/mcp" + } + } +} +``` + +On macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` + +### VS Code / GitHub Copilot + +**URL mode** (if your client supports it): + +```json +{ + "github.copilot.chat.mcp.servers": { + "github": { + "url": "http://localhost:8080/mcp" + } + } +} +``` + +**Command mode** (stdio-only clients): + +```json +{ + "github.copilot.chat.mcp.servers": { + "github": { + "command": "java", + "args": ["-jar", "/path/to/github-mcp-server-1.0.0.jar"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..." + } + } + } +} +``` + +--- + +## Docker + +```bash +# Build image +docker build -t github-mcp-server:latest . + +# Run +docker run --rm -p 8080:8080 \ + -e GITHUB_PERSONAL_ACCESS_TOKEN=ghp_... \ + github-mcp-server:latest +``` + +If GitHub Enterprise runs in Docker on the same host, use `host.docker.internal`: + +```bash +-e GITHUB_HOST=http://host.docker.internal:3000 +``` + +--- + +## Running tests + +```bash +mvn test +``` + +--- + +## Package structure + +``` +com.github.mcp +├── GithubMcpApplication.java @SpringBootApplication +├── config/ +│ ├── GitHubProperties.java @ConfigurationProperties — token, host, readOnly, toolsets +│ └── WebConfig.java Web configuration +├── client/ +│ ├── GitHubRestClient.java GET/POST/PATCH/DELETE HTTP gateway; typed exceptions +│ └── GitHubGraphqlClient.java GraphQL client +├── controller/ +│ └── HealthController.java Health check endpoint (GET /health) +├── exception/ +│ ├── GitHubMcpException.java Base exception +│ └── GlobalExceptionHandler.java Global error handler +├── dto/ +│ ├── common/ ApiResponse · PagedResponse +│ ├── request/ *Request DTOs +│ └── response/ *Response DTOs +├── service/ Interfaces + impl — business logic, error mapping, pagination +└── tools/ + ├── RepositoryTools.java (11 tools) + ├── IssueTools.java (11 tools) + ├── PullRequestTools.java (10 tools) + ├── SearchTools.java (4 tools) + └── UserTools.java (3 tools) +``` + +--- + +## Troubleshooting + +### `401 Unauthorized` + +Token issue. Check: + +1. `GITHUB_PERSONAL_ACCESS_TOKEN` is set correctly +2. The token has not expired +3. The token has the required scopes (see [Configuration](#configuration)) +4. For GitHub Enterprise: confirm `GITHUB_HOST` is set to your instance URL + +### `REPO_NOT_FOUND` / `404 Not Found` + +The repository may be private and the token lacks `repo` scope, or the owner/name is incorrect. + +### Connection timeouts + +The server connects to `api.github.com` (or your `GITHUB_HOST`). Ensure the JVM process has outbound network access. + +### Copilot / Claude can't see the server + +1. Confirm the server is running: `curl http://localhost:8080/health` +2. Confirm the MCP SSE endpoint is alive: `curl http://localhost:8080/mcp` +3. Check that the URL in the client config points to `http://localhost:8080/mcp` + +--- + +## Acknowledgments + +This project is a Java / Spring AI port of the official [GitHub MCP Server](https://github.com/github/github-mcp-server) written in Go. + +--- diff --git a/model-context-protocol/github-mcp-server/pom.xml b/model-context-protocol/github-mcp-server/pom.xml new file mode 100644 index 00000000..781a44bb --- /dev/null +++ b/model-context-protocol/github-mcp-server/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.3 + + + com.github + github-mcp-server + 1.0.0 + github-mcp-server + GitHub MCP Server - Java/Spring AI Implementation + + + 21 + 21 + 21 + UTF-8 + 2.0.0-M3 + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + org.projectlombok + lombok + provided + + + + + org.apache.httpcomponents.client5 + httpclient5 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/GithubMcpApplication.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/GithubMcpApplication.java new file mode 100644 index 00000000..3f1a647f --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/GithubMcpApplication.java @@ -0,0 +1,13 @@ +package com.github.mcp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class GithubMcpApplication { + public static void main(String[] args) { + SpringApplication.run(GithubMcpApplication.class, args); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/client/GitHubGraphqlClient.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/client/GitHubGraphqlClient.java new file mode 100644 index 00000000..058edcd2 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/client/GitHubGraphqlClient.java @@ -0,0 +1,63 @@ +package com.github.mcp.client; + +import com.github.mcp.config.GitHubProperties; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GitHubGraphqlClient { + + private final RestTemplate restTemplate; + private final GitHubProperties properties; + + @Getter + private String graphqlUrl; + + @PostConstruct + public void init() { + if ("github.com".equals(properties.getHost())) { + this.graphqlUrl = "https://api.github.com/graphql"; + } else { + this.graphqlUrl = "https://" + properties.getHost() + "/api/graphql"; + } + log.info("GitHub GraphQL API URL: {}", graphqlUrl); + } + + public T execute(String query, Map variables, Class responseType) { + GraphqlRequest request = new GraphqlRequest(query, variables); + HttpEntity entity = new HttpEntity<>(request, buildHeaders()); + ResponseEntity response = restTemplate.exchange( + URI.create(graphqlUrl), + HttpMethod.POST, + entity, + responseType + ); + return response.getBody(); + } + + public T execute(String query, Class responseType) { + return execute(query, null, responseType); + } + + private HttpHeaders buildHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON)); + headers.set("Authorization", "Bearer " + properties.getPersonalAccessToken()); + headers.set("User-Agent", "github-mcp-server/1.0.0"); + return headers; + } + + public record GraphqlRequest(String query, Map variables) { + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/client/GitHubRestClient.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/client/GitHubRestClient.java new file mode 100644 index 00000000..86b2aa2f --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/client/GitHubRestClient.java @@ -0,0 +1,135 @@ +package com.github.mcp.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mcp.config.GitHubProperties; +import com.github.mcp.exception.GitHubMcpException; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GitHubRestClient { + + private final RestTemplate restTemplate; + private final GitHubProperties properties; + private final ObjectMapper objectMapper; + + @Getter + private String baseUrl; + + @PostConstruct + public void init() { + if ("github.com".equals(properties.getHost())) { + this.baseUrl = "https://api.github.com"; + } else { + this.baseUrl = "https://" + properties.getHost() + "/api/v3"; + } + log.info("GitHub REST API base URL: {}", baseUrl); + } + + public T get(String path, Class responseType) { + return get(path, null, responseType); + } + + public T get(String path, Map params, Class responseType) { + URI uri = buildUri(path, params); + return exchange(uri, HttpMethod.GET, null, responseType); + } + + public T post(String path, Object body, Class responseType) { + URI uri = buildUri(path, null); + return exchange(uri, HttpMethod.POST, body, responseType); + } + + public T put(String path, Object body, Class responseType) { + URI uri = buildUri(path, null); + return exchange(uri, HttpMethod.PUT, body, responseType); + } + + public T patch(String path, Object body, Class responseType) { + URI uri = buildUri(path, null); + return exchange(uri, HttpMethod.PATCH, body, responseType); + } + + public void delete(String path) { + URI uri = buildUri(path, null); + HttpEntity entity = new HttpEntity<>(buildHeaders()); + restTemplate.exchange(uri, HttpMethod.DELETE, entity, Void.class); + } + + public ResponseEntity getRawResponse(String path, Map params) { + URI uri = buildUri(path, params); + HttpEntity entity = new HttpEntity<>(buildHeaders()); + try { + return restTemplate.exchange(uri, HttpMethod.GET, entity, String.class); + } catch (RestClientResponseException ex) { + throw buildGitHubException(HttpMethod.GET, uri, ex); + } + } + + private HttpHeaders buildHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(java.util.List.of(MediaType.APPLICATION_JSON, MediaType.valueOf("application/vnd.github+json"))); + if (StringUtils.hasText(properties.getPersonalAccessToken())) { + headers.setBearerAuth(properties.getPersonalAccessToken()); + } + headers.set("User-Agent", "github-mcp-server/1.0.0"); + headers.set("X-GitHub-Api-Version", "2022-11-28"); + return headers; + } + + private T exchange(URI uri, HttpMethod method, Object body, Class responseType) { + HttpEntity entity = new HttpEntity<>(body, buildHeaders()); + try { + ResponseEntity response = restTemplate.exchange(uri, method, entity, String.class); + if (responseType == Void.class || response.getBody() == null || response.getBody().isBlank()) { + return null; + } + return objectMapper.readValue(response.getBody(), responseType); + } catch (RestClientResponseException ex) { + throw buildGitHubException(method, uri, ex); + } catch (Exception ex) { + throw new GitHubMcpException("DESERIALIZATION_ERROR", + String.format("Failed to parse GitHub response for %s %s: %s", method, uri, ex.getMessage()), + 500); + } + } + + private GitHubMcpException buildGitHubException(HttpMethod method, URI uri, RestClientResponseException ex) { + String body = ex.getResponseBodyAsString(); + String message = String.format("GitHub API %s %s failed with %d %s", + method, + uri, + ex.getStatusCode().value(), + ex.getStatusText()); + if (StringUtils.hasText(body)) { + message += ": " + body; + } + return new GitHubMcpException("HTTP_" + ex.getStatusCode().value(), message, ex.getStatusCode().value()); + } + + private URI buildUri(String path, Map params) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(baseUrl + path); + if (params != null) { + params.forEach((key, value) -> { + if (value != null) { + builder.queryParam(key, value); + } + }); + } + return builder.build().toUri(); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/config/GitHubProperties.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/config/GitHubProperties.java new file mode 100644 index 00000000..310f8428 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/config/GitHubProperties.java @@ -0,0 +1,29 @@ +package com.github.mcp.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; +import java.util.List; + +@Data +@Validated +@ConfigurationProperties(prefix = "github") +public class GitHubProperties { + private String personalAccessToken; + private String host = "github.com"; + private List toolsets; + private List tools; + private List excludeTools; + private List features; + private boolean dynamicToolsets = false; + private boolean readOnly = false; + private String logFile; + private boolean enableCommandLogging = false; + private boolean exportTranslations = false; + private int contentWindowSize = 5000; + private boolean lockdownMode = false; + private boolean insiders = false; + private Duration repoAccessCacheTtl = Duration.ofMinutes(5); +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/config/WebConfig.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/config/WebConfig.java new file mode 100644 index 00000000..6d878800 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/config/WebConfig.java @@ -0,0 +1,29 @@ +package com.github.mcp.config; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class WebConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.findAndRegisterModules(); + mapper.registerModule(new JavaTimeModule()); + mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(new HttpComponentsClientHttpRequestFactory()); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/controller/HealthController.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/controller/HealthController.java new file mode 100644 index 00000000..89b3c1e0 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/controller/HealthController.java @@ -0,0 +1,24 @@ +package com.github.mcp.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class HealthController { + + @GetMapping("/health") + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of( + "status", "UP", + "service", "github-mcp-server", + "version", "1.0.0")); + } + + @GetMapping("/ping") + Map ping() { + return Map.of("status", "ok"); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/common/ApiResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/common/ApiResponse.java new file mode 100644 index 00000000..c86bb1d2 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/common/ApiResponse.java @@ -0,0 +1,34 @@ +package com.github.mcp.dto.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + private boolean success; + private T data; + private String errorCode; + private String errorMessage; + + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .data(data) + .build(); + } + + public static ApiResponse error(String errorCode, String errorMessage) { + return ApiResponse.builder() + .success(false) + .errorCode(errorCode) + .errorMessage(errorMessage) + .build(); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/common/PagedResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/common/PagedResponse.java new file mode 100644 index 00000000..605c6f76 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/common/PagedResponse.java @@ -0,0 +1,39 @@ +package com.github.mcp.dto.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PagedResponse { + private List items; + private int page; + private int perPage; + private long totalCount; + private int totalPages; + private boolean hasNext; + private boolean hasPrevious; + private String nextPageUrl; + private String prevPageUrl; + + public static PagedResponse of(List items, int page, int perPage, long totalCount) { + int totalPages = (int) Math.ceil((double) totalCount / perPage); + return PagedResponse.builder() + .items(items) + .page(page) + .perPage(perPage) + .totalCount(totalCount) + .totalPages(totalPages) + .hasNext(page < totalPages) + .hasPrevious(page > 1) + .build(); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateBranchRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateBranchRequest.java new file mode 100644 index 00000000..6706e01c --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateBranchRequest.java @@ -0,0 +1,15 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateBranchRequest { + private String ref; + private String sha; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateFileRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateFileRequest.java new file mode 100644 index 00000000..3ba39322 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateFileRequest.java @@ -0,0 +1,29 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateFileRequest { + private String message; + private String content; + private String sha; + private String branch; + private Committer committer; + private Committer author; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Committer { + private String name; + private String email; + private String date; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateGistRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateGistRequest.java new file mode 100644 index 00000000..fc2afb2e --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateGistRequest.java @@ -0,0 +1,26 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateGistRequest { + private String description; + private Boolean public_gist; + private Map files; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class GistFile { + private String content; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateIssueRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateIssueRequest.java new file mode 100644 index 00000000..3d539a3e --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateIssueRequest.java @@ -0,0 +1,21 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateIssueRequest { + private String title; + private String body; + private String assignee; + private List assignees; + private List labels; + private Long milestone; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreatePullRequestRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreatePullRequestRequest.java new file mode 100644 index 00000000..d5fee879 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreatePullRequestRequest.java @@ -0,0 +1,21 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreatePullRequestRequest { + private String title; + private String body; + private String head; + private String base; + private Boolean draft; + private Boolean maintainerCanModify; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateRepositoryRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateRepositoryRequest.java new file mode 100644 index 00000000..1fa877f7 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/CreateRepositoryRequest.java @@ -0,0 +1,30 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateRepositoryRequest { + private String name; + private String description; + private String homepage; + private Boolean private_repo; + private Boolean hasIssues; + private Boolean hasProjects; + private Boolean hasWiki; + private Boolean hasDownloads; + private Boolean isTemplate; + private String teamId; + private Boolean autoInit; + private String gitignoreTemplate; + private String licenseTemplate; + private Boolean allowSquashMerge; + private Boolean allowMergeCommit; + private Boolean allowRebaseMerge; + private Boolean deleteBranchOnMerge; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/MergePullRequestRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/MergePullRequestRequest.java new file mode 100644 index 00000000..0a1426ab --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/MergePullRequestRequest.java @@ -0,0 +1,17 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MergePullRequestRequest { + private String commitTitle; + private String commitMessage; + private String sha; + private String mergeMethod; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/UpdateIssueRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/UpdateIssueRequest.java new file mode 100644 index 00000000..68def6ba --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/UpdateIssueRequest.java @@ -0,0 +1,23 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdateIssueRequest { + private String title; + private String body; + private String state; + private String stateReason; + private String assignee; + private List assignees; + private List labels; + private Long milestone; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/UpdatePullRequestRequest.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/UpdatePullRequestRequest.java new file mode 100644 index 00000000..6a39e782 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/request/UpdatePullRequestRequest.java @@ -0,0 +1,18 @@ +package com.github.mcp.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpdatePullRequestRequest { + private String title; + private String body; + private String state; + private String base; + private Boolean maintainerCanModify; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/BranchResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/BranchResponse.java new file mode 100644 index 00000000..227f3d70 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/BranchResponse.java @@ -0,0 +1,56 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class BranchResponse { + private String name; + private CommitRef commit; + private Boolean protected_branch; + private Protection protection; + private String protectionUrl; + + @JsonIgnoreProperties(ignoreUnknown = true) + public void setProtected(Boolean protected_branch) { + this.protected_branch = protected_branch; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CommitRef { + private String sha; + private String url; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Protection { + private Boolean enabled; + private RequiredStatusChecks requiredStatusChecks; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class RequiredStatusChecks { + private String enforcementLevel; + private java.util.List contexts; + private Integer contextsUrl; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/CodeScanningAlertResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/CodeScanningAlertResponse.java new file mode 100644 index 00000000..99adb3ad --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/CodeScanningAlertResponse.java @@ -0,0 +1,62 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class CodeScanningAlertResponse { + private Long number; + private String nodeId; + private String state; + private String dismissalReason; + private String dismissedAt; + private String dismissedBy; + private String rule; + private String tool; + private MostRecentInstance mostRecentInstance; + private String instancesUrl; + private String url; + private String htmlUrl; + private String fixedAt; + private String fixedBy; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class MostRecentInstance { + private String ref; + private String analysisKey; + private String environment; + private String state; + private String commitSha; + private String message; + private Location location; + private String htmlUrl; + private String classifications; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Location { + private String path; + private Integer startLine; + private Integer endLine; + private Integer startColumn; + private Integer endColumn; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/CommitResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/CommitResponse.java new file mode 100644 index 00000000..dc015d9d --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/CommitResponse.java @@ -0,0 +1,116 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class CommitResponse { + private String sha; + private String nodeId; + private String htmlUrl; + private String url; + private CommitDetail commit; + private UserResponse author; + private UserResponse committer; + private List parents; + private Stats stats; + private List files; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CommitDetail { + private String message; + private CommitAuthor author; + private CommitAuthor committer; + private Tree tree; + private Integer commentCount; + private Verification verification; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CommitAuthor { + private String name; + private String email; + private Instant date; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Tree { + private String sha; + private String url; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Verification { + private Boolean verified; + private String reason; + private String signature; + private String payload; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ParentCommit { + private String sha; + private String url; + private String htmlUrl; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Stats { + private Integer additions; + private Integer deletions; + private Integer total; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FileChange { + private String sha; + private String filename; + private String status; + private Integer additions; + private Integer deletions; + private Integer changes; + private String blobUrl; + private String rawUrl; + private String contentsUrl; + private String patch; + private String previousFilename; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/FileContentResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/FileContentResponse.java new file mode 100644 index 00000000..3adad7e3 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/FileContentResponse.java @@ -0,0 +1,38 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FileContentResponse { + private String type; + private String encoding; + private Integer size; + private String name; + private String path; + private String content; + private String sha; + private String url; + private String gitUrl; + private String htmlUrl; + private String downloadUrl; + private Links links; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Links { + private String git; + private String self; + private String html; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/GistResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/GistResponse.java new file mode 100644 index 00000000..fc071034 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/GistResponse.java @@ -0,0 +1,51 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class GistResponse { + private String url; + private String forksUrl; + private String commitsUrl; + private String id; + private String nodeId; + private String gitPullUrl; + private String gitPushUrl; + private String htmlUrl; + private Map files; + private Boolean public_gist; + private String createdAt; + private String updatedAt; + private String description; + private Integer comments; + private UserResponse user; + private String commentsUrl; + private String owner; + private Boolean truncated; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class GistFile { + private String filename; + private String type; + private String language; + private String rawUrl; + private Integer size; + private Boolean truncated; + private String content; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/IssueResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/IssueResponse.java new file mode 100644 index 00000000..ea5b61d4 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/IssueResponse.java @@ -0,0 +1,46 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class IssueResponse { + private Long id; + private Long number; + private String title; + private String body; + private String state; + private String stateReason; + private String htmlUrl; + private UserResponse user; + private List assignees; + private List labels; + private MilestoneResponse milestone; + private Boolean locked; + private Integer comments; + private PullRequestReference pullRequest; + private Instant createdAt; + private Instant updatedAt; + private Instant closedAt; + private UserResponse closedBy; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PullRequestReference { + private String url; + private String htmlUrl; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/LabelResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/LabelResponse.java new file mode 100644 index 00000000..a07ea64b --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/LabelResponse.java @@ -0,0 +1,27 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class LabelResponse { + private Long id; + private String nodeId; + private String url; + private String name; + private String description; + private String color; + private Boolean default_label; + + @JsonIgnoreProperties(ignoreUnknown = true) + public void setDefault(Boolean default_label) { + this.default_label = default_label; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/MilestoneResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/MilestoneResponse.java new file mode 100644 index 00000000..cb2e1559 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/MilestoneResponse.java @@ -0,0 +1,33 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class MilestoneResponse { + private Long id; + private Long number; + private String nodeId; + private String url; + private String htmlUrl; + private String labelsUrl; + private String title; + private String description; + private String state; + private Integer openIssues; + private Integer closedIssues; + private Instant createdAt; + private Instant updatedAt; + private Instant dueOn; + private Instant closedAt; + private UserResponse creator; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/NotificationResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/NotificationResponse.java new file mode 100644 index 00000000..3b4a9006 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/NotificationResponse.java @@ -0,0 +1,39 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class NotificationResponse { + private String id; + private String repositoryId; + private String unread; + private String reason; + private Instant updatedAt; + private Instant lastReadAt; + private String url; + private Subject subject; + private RepositoryResponse repository; + private String subscriptionUrl; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Subject { + private String title; + private String url; + private String latestCommentUrl; + private String type; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/PullRequestResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/PullRequestResponse.java new file mode 100644 index 00000000..03de35dc --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/PullRequestResponse.java @@ -0,0 +1,63 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class PullRequestResponse { + private Long id; + private Long number; + private String title; + private String body; + private String state; + private String htmlUrl; + private String diffUrl; + private String patchUrl; + private UserResponse user; + private List assignees; + private List labels; + private MilestoneResponse milestone; + private Boolean locked; + private Boolean draft; + private String mergeCommitSha; + private UserResponse mergedBy; + private Instant mergedAt; + private String mergeStateStatus; + private Boolean mergeable; + private Boolean rebaseable; + private String mergeableState; + private Integer comments; + private Integer reviewComments; + private Integer commits; + private Integer additions; + private Integer deletions; + private Integer changedFiles; + private BranchReference head; + private BranchReference base; + private Instant createdAt; + private Instant updatedAt; + private Instant closedAt; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class BranchReference { + private String label; + private String ref; + private String sha; + private RepositoryResponse repo; + private UserResponse user; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/RepositoryResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/RepositoryResponse.java new file mode 100644 index 00000000..852b20c6 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/RepositoryResponse.java @@ -0,0 +1,81 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class RepositoryResponse { + private Long id; + private String name; + private String fullName; + private String description; + private String url; + private String htmlUrl; + private String cloneUrl; + private String sshUrl; + private Boolean private_repo; + private Boolean fork; + + @JsonProperty("private") + public void setPrivate(Boolean private_repo) { + this.private_repo = private_repo; + } + + @JsonProperty("private") + public Boolean getPrivate() { + return private_repo; + } + + private String defaultBranch; + private Integer forksCount; + private Integer stargazersCount; + private Integer watchersCount; + private Integer openIssuesCount; + private String language; + private Instant createdAt; + private Instant updatedAt; + private Instant pushedAt; + private Owner owner; + private License license; + private Boolean archived; + private Boolean disabled; + private String visibility; + private List topics; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Owner { + private String login; + private Long id; + private String avatarUrl; + private String htmlUrl; + private String type; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class License { + private String key; + private String name; + private String spdxId; + private String url; + } + +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/SearchResultResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/SearchResultResponse.java new file mode 100644 index 00000000..421e0ca6 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/SearchResultResponse.java @@ -0,0 +1,20 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class SearchResultResponse { + private Integer totalCount; + private Boolean incompleteResults; + private List items; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/UserResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/UserResponse.java new file mode 100644 index 00000000..00408aa7 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/UserResponse.java @@ -0,0 +1,48 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserResponse { + private String login; + private Long id; + private String nodeId; + private String avatarUrl; + private String gravatarId; + private String url; + private String htmlUrl; + private String followersUrl; + private String followingUrl; + private String gistsUrl; + private String starredUrl; + private String subscriptionsUrl; + private String organizationsUrl; + private String reposUrl; + private String eventsUrl; + private String receivedEventsUrl; + private String type; + private Boolean siteAdmin; + private String name; + private String company; + private String blog; + private String location; + private String email; + private String bio; + private String twitterUsername; + private Integer publicRepos; + private Integer publicGists; + private Integer followers; + private Integer following; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/WorkflowResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/WorkflowResponse.java new file mode 100644 index 00000000..0397c62d --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/WorkflowResponse.java @@ -0,0 +1,28 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class WorkflowResponse { + private Long id; + private String nodeId; + private String name; + private String path; + private String state; + private Long repositoryId; + private String url; + private String htmlUrl; + private String badgeUrl; + private Instant createdAt; + private Instant updatedAt; +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/WorkflowRunResponse.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/WorkflowRunResponse.java new file mode 100644 index 00000000..cb9492e2 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/dto/response/WorkflowRunResponse.java @@ -0,0 +1,59 @@ +package com.github.mcp.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class WorkflowRunResponse { + private Long id; + private String name; + private String nodeId; + private Long repositoryId; + private String headBranch; + private String headSha; + private String path; + private String displayTitle; + private Long runNumber; + private String event; + private String status; + private String conclusion; + private Long workflowId; + private String url; + private String htmlUrl; + private String logsUrl; + private String checkSuiteUrl; + private String artifactsUrl; + private String cancelUrl; + private String rerunUrl; + private String workflowUrl; + private HeadCommit headCommit; + private RepositoryResponse repository; + private UserResponse triggeringActor; + private Instant createdAt; + private Instant updatedAt; + private Instant runStartedAt; + private Integer jobsUrl; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class HeadCommit { + private String id; + private String treeId; + private String message; + private Instant timestamp; + private String author; + private String committer; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/exception/GitHubMcpException.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/exception/GitHubMcpException.java new file mode 100644 index 00000000..2acf8c1f --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/exception/GitHubMcpException.java @@ -0,0 +1,33 @@ +package com.github.mcp.exception; + +import lombok.Getter; + +@Getter +public class GitHubMcpException extends RuntimeException { + private final String code; + private final int statusCode; + + public GitHubMcpException(String message) { + super(message); + this.code = "GITHUB_ERROR"; + this.statusCode = 500; + } + + public GitHubMcpException(String code, String message) { + super(message); + this.code = code; + this.statusCode = 500; + } + + public GitHubMcpException(String code, String message, int statusCode) { + super(message); + this.code = code; + this.statusCode = statusCode; + } + + public GitHubMcpException(String message, Throwable cause) { + super(message, cause); + this.code = "GITHUB_ERROR"; + this.statusCode = 500; + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/exception/GlobalExceptionHandler.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..2d3cc77a --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/exception/GlobalExceptionHandler.java @@ -0,0 +1,74 @@ +package com.github.mcp.exception; + +import com.github.mcp.dto.common.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(GitHubMcpException.class) + public ResponseEntity> handleGitHubMcpException(GitHubMcpException ex) { + log.error("GitHub MCP Exception: {}", ex.getMessage()); + return ResponseEntity + .status(ex.getStatusCode()) + .body(ApiResponse.error(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(HttpClientErrorException.class) + public ResponseEntity> handleHttpClientError(HttpClientErrorException ex) { + log.error("HTTP Client Error: {}", ex.getMessage()); + String code = "HTTP_" + ex.getStatusCode().value(); + return ResponseEntity + .status(ex.getStatusCode()) + .body(ApiResponse.error(code, extractGitHubErrorMessage(ex))); + } + + @ExceptionHandler(HttpServerErrorException.class) + public ResponseEntity> handleHttpServerError(HttpServerErrorException ex) { + log.error("HTTP Server Error: {}", ex.getMessage()); + String code = "HTTP_" + ex.getStatusCode().value(); + return ResponseEntity + .status(ex.getStatusCode()) + .body(ApiResponse.error(code, "GitHub API server error")); + } + + @ExceptionHandler(ResourceAccessException.class) + public ResponseEntity> handleResourceAccess(ResourceAccessException ex) { + log.error("Resource Access Error: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ApiResponse.error("CONNECTION_ERROR", "Unable to connect to GitHub API")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("INTERNAL_ERROR", "An unexpected error occurred")); + } + + private String extractGitHubErrorMessage(HttpClientErrorException ex) { + try { + String responseBody = ex.getResponseBodyAsString(); + if (responseBody != null && responseBody.contains("message")) { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var jsonNode = mapper.readTree(responseBody); + if (jsonNode.has("message")) { + return jsonNode.get("message").asText(); + } + } + } catch (Exception e) { + log.debug("Could not parse error response"); + } + return ex.getMessage(); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/IssueServiceImpl.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/IssueServiceImpl.java new file mode 100644 index 00000000..e6db9b05 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/IssueServiceImpl.java @@ -0,0 +1,181 @@ +package com.github.mcp.service.impl; + +import com.github.mcp.client.GitHubRestClient; +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreateIssueRequest; +import com.github.mcp.dto.request.UpdateIssueRequest; +import com.github.mcp.dto.response.IssueResponse; +import com.github.mcp.service.interfaces.IssueService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class IssueServiceImpl implements IssueService { + + private final GitHubRestClient gitHubClient; + + @Override + public ApiResponse createIssue(String owner, String repo, CreateIssueRequest request) { + try { + String path = String.format("/repos/%s/%s/issues", owner, repo); + IssueResponse response = gitHubClient.post(path, request, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error creating issue: {}", e.getMessage()); + return ApiResponse.error("CREATE_ERROR", "Failed to create issue: " + e.getMessage()); + } + } + + @Override + public ApiResponse updateIssue(String owner, String repo, Long issueNumber, UpdateIssueRequest request) { + try { + String path = String.format("/repos/%s/%s/issues/%d", owner, repo, issueNumber); + IssueResponse response = gitHubClient.patch(path, request, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error updating issue: {}", e.getMessage()); + return ApiResponse.error("UPDATE_ERROR", "Failed to update issue: " + e.getMessage()); + } + } + + @Override + public ApiResponse addIssueComment(String owner, String repo, Long issueNumber, String body) { + try { + String path = String.format("/repos/%s/%s/issues/%d/comments", owner, repo, issueNumber); + Map request = new HashMap<>(); + request.put("body", body); + IssueResponse response = gitHubClient.post(path, request, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error adding issue comment: {}", e.getMessage()); + return ApiResponse.error("COMMENT_ERROR", "Failed to add comment: " + e.getMessage()); + } + } + + @Override + public ApiResponse> listIssues(String owner, String repo, String state, String labels, String assignee, Integer page, Integer perPage) { + try { + String url = String.format("/repos/%s/%s/issues", owner, repo); + Map params = new HashMap<>(); + if (state != null) params.put("state", state); + if (labels != null) params.put("labels", labels); + if (assignee != null) params.put("assignee", assignee); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse(url, params); + List issues = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(rawResponse.getBody(), new TypeReference>() {}); + + return ApiResponse.success(PagedResponse.of(issues, page != null ? page : 1, perPage != null ? perPage : 30, issues.size())); + } catch (Exception e) { + log.error("Error listing issues: {}", e.getMessage()); + return ApiResponse.error("LIST_ERROR", "Failed to list issues: " + e.getMessage()); + } + } + + @Override + public ApiResponse getIssue(String owner, String repo, Long issueNumber) { + try { + String path = String.format("/repos/%s/%s/issues/%d", owner, repo, issueNumber); + IssueResponse response = gitHubClient.get(path, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error getting issue: {}", e.getMessage()); + return ApiResponse.error("GET_ERROR", "Failed to get issue: " + e.getMessage()); + } + } + + @Override + public ApiResponse closeIssue(String owner, String repo, Long issueNumber) { + try { + String path = String.format("/repos/%s/%s/issues/%d", owner, repo, issueNumber); + Map body = new HashMap<>(); + body.put("state", "closed"); + IssueResponse response = gitHubClient.patch(path, body, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error closing issue: {}", e.getMessage()); + return ApiResponse.error("CLOSE_ERROR", "Failed to close issue: " + e.getMessage()); + } + } + + @Override + public ApiResponse reopenIssue(String owner, String repo, Long issueNumber) { + try { + String path = String.format("/repos/%s/%s/issues/%d", owner, repo, issueNumber); + Map body = new HashMap<>(); + body.put("state", "open"); + IssueResponse response = gitHubClient.patch(path, body, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error reopening issue: {}", e.getMessage()); + return ApiResponse.error("REOPEN_ERROR", "Failed to reopen issue: " + e.getMessage()); + } + } + + @Override + public ApiResponse assignIssue(String owner, String repo, Long issueNumber, List assignees) { + try { + String path = String.format("/repos/%s/%s/issues/%d/assignees", owner, repo, issueNumber); + Map> body = new HashMap<>(); + body.put("assignees", assignees); + IssueResponse response = gitHubClient.post(path, body, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error assigning issue: {}", e.getMessage()); + return ApiResponse.error("ASSIGN_ERROR", "Failed to assign issue: " + e.getMessage()); + } + } + + @Override + public ApiResponse unassignIssue(String owner, String repo, Long issueNumber) { + try { + String path = String.format("/repos/%s/%s/issues/%d/assignees", owner, repo, issueNumber); + Map> body = new HashMap<>(); + body.put("assignees", List.of()); + IssueResponse response = gitHubClient.post(path, body, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error unassigning issue: {}", e.getMessage()); + return ApiResponse.error("UNASSIGN_ERROR", "Failed to unassign issue: " + e.getMessage()); + } + } + + @Override + public ApiResponse addIssueLabels(String owner, String repo, Long issueNumber, List labels) { + try { + String path = String.format("/repos/%s/%s/issues/%d/labels", owner, repo, issueNumber); + Map> body = new HashMap<>(); + body.put("labels", labels); + IssueResponse response = gitHubClient.post(path, body, IssueResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error adding labels: {}", e.getMessage()); + return ApiResponse.error("LABEL_ERROR", "Failed to add labels: " + e.getMessage()); + } + } + + @Override + public ApiResponse removeIssueLabel(String owner, String repo, Long issueNumber, String label) { + try { + String path = String.format("/repos/%s/%s/issues/%d/labels/%s", owner, repo, issueNumber, label); + gitHubClient.delete(path); + IssueResponse issue = getIssue(owner, repo, issueNumber).getData(); + return ApiResponse.success(issue); + } catch (Exception e) { + log.error("Error removing label: {}", e.getMessage()); + return ApiResponse.error("LABEL_ERROR", "Failed to remove label: " + e.getMessage()); + } + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/PullRequestServiceImpl.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/PullRequestServiceImpl.java new file mode 100644 index 00000000..81f84a23 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/PullRequestServiceImpl.java @@ -0,0 +1,180 @@ +package com.github.mcp.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mcp.client.GitHubRestClient; +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreatePullRequestRequest; +import com.github.mcp.dto.request.MergePullRequestRequest; +import com.github.mcp.dto.request.UpdatePullRequestRequest; +import com.github.mcp.dto.response.PullRequestResponse; +import com.github.mcp.service.interfaces.PullRequestService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PullRequestServiceImpl implements PullRequestService { + + private final GitHubRestClient gitHubClient; + private final ObjectMapper objectMapper; + + @Override + public ApiResponse createPullRequest(String owner, String repo, CreatePullRequestRequest request) { + try { + String path = String.format("/repos/%s/%s/pulls", owner, repo); + PullRequestResponse response = gitHubClient.post(path, request, PullRequestResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error creating pull request: {}", e.getMessage()); + return ApiResponse.error("CREATE_ERROR", "Failed to create pull request: " + e.getMessage()); + } + } + + @Override + public ApiResponse updatePullRequest(String owner, String repo, Long pullNumber, UpdatePullRequestRequest request) { + try { + String path = String.format("/repos/%s/%s/pulls/%d", owner, repo, pullNumber); + PullRequestResponse response = gitHubClient.patch(path, request, PullRequestResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error updating pull request: {}", e.getMessage()); + return ApiResponse.error("UPDATE_ERROR", "Failed to update pull request: " + e.getMessage()); + } + } + + @Override + public ApiResponse> listPullRequests(String owner, String repo, String state, String head, String base, String sort, String direction, Integer page, Integer perPage) { + try { + String url = String.format("/repos/%s/%s/pulls", owner, repo); + Map params = new HashMap<>(); + if (state != null) params.put("state", state); + if (head != null) params.put("head", head); + if (base != null) params.put("base", base); + if (sort != null) params.put("sort", sort); + if (direction != null) params.put("direction", direction); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse(url, params); + List pulls = objectMapper.readerForListOf(PullRequestResponse.class) + .readValue(rawResponse.getBody()); + + return ApiResponse.success(PagedResponse.of(pulls, page != null ? page : 1, perPage != null ? perPage : 30, pulls.size())); + } catch (Exception e) { + log.error("Error listing pull requests: {}", e.getMessage()); + return ApiResponse.error("LIST_ERROR", "Failed to list pull requests: " + e.getMessage()); + } + } + + @Override + public ApiResponse getPullRequest(String owner, String repo, Long pullNumber) { + try { + String path = String.format("/repos/%s/%s/pulls/%d", owner, repo, pullNumber); + PullRequestResponse response = gitHubClient.get(path, PullRequestResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error getting pull request: {}", e.getMessage()); + return ApiResponse.error("GET_ERROR", "Failed to get pull request: " + e.getMessage()); + } + } + + @Override + public ApiResponse mergePullRequest(String owner, String repo, Long pullNumber, MergePullRequestRequest request) { + try { + String path = String.format("/repos/%s/%s/pulls/%d/merge", owner, repo, pullNumber); + Map response = gitHubClient.put(path, request, Map.class); + PullRequestResponse pr = getPullRequest(owner, repo, pullNumber).getData(); + return ApiResponse.success(pr); + } catch (Exception e) { + log.error("Error merging pull request: {}", e.getMessage()); + return ApiResponse.error("MERGE_ERROR", "Failed to merge pull request: " + e.getMessage()); + } + } + + @Override + public ApiResponse closePullRequest(String owner, String repo, Long pullNumber) { + try { + String path = String.format("/repos/%s/%s/pulls/%d", owner, repo, pullNumber); + Map body = new HashMap<>(); + body.put("state", "closed"); + PullRequestResponse response = gitHubClient.patch(path, body, PullRequestResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error closing pull request: {}", e.getMessage()); + return ApiResponse.error("CLOSE_ERROR", "Failed to close pull request: " + e.getMessage()); + } + } + + @Override + public ApiResponse reopenPullRequest(String owner, String repo, Long pullNumber) { + try { + String path = String.format("/repos/%s/%s/pulls/%d", owner, repo, pullNumber); + Map body = new HashMap<>(); + body.put("state", "open"); + PullRequestResponse response = gitHubClient.patch(path, body, PullRequestResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error reopening pull request: {}", e.getMessage()); + return ApiResponse.error("REOPEN_ERROR", "Failed to reopen pull request: " + e.getMessage()); + } + } + + @Override + public ApiResponse addPullRequestComment(String owner, String repo, Long pullNumber, String body, String commitId, String path, Integer position) { + try { + String apiPath = String.format("/repos/%s/%s/pulls/%d/comments", owner, repo, pullNumber); + Map request = new HashMap<>(); + request.put("body", body); + if (commitId != null) request.put("commit_id", commitId); + if (path != null) request.put("path", path); + if (position != null) request.put("position", position); + + PullRequestResponse response = gitHubClient.post(apiPath, request, PullRequestResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error adding pull request comment: {}", e.getMessage()); + return ApiResponse.error("COMMENT_ERROR", "Failed to add comment: " + e.getMessage()); + } + } + + @Override + public ApiResponse createPullRequestReview(String owner, String repo, Long pullNumber, String body, String event, Object comments) { + try { + String path = String.format("/repos/%s/%s/pulls/%d/reviews", owner, repo, pullNumber); + Map request = new HashMap<>(); + if (body != null) request.put("body", body); + if (event != null) request.put("event", event); + if (comments != null) request.put("comments", comments); + + Object response = gitHubClient.post(path, request, Object.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error creating pull request review: {}", e.getMessage()); + return ApiResponse.error("REVIEW_ERROR", "Failed to create review: " + e.getMessage()); + } + } + + @Override + public ApiResponse submitPullRequestReview(String owner, String repo, Long pullNumber, Long reviewId, String body, String event) { + try { + String path = String.format("/repos/%s/%s/pulls/%d/reviews/%d/events", owner, repo, pullNumber, reviewId); + Map request = new HashMap<>(); + if (body != null) request.put("body", body); + if (event != null) request.put("event", event); + + Object response = gitHubClient.post(path, request, Object.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error submitting pull request review: {}", e.getMessage()); + return ApiResponse.error("REVIEW_ERROR", "Failed to submit review: " + e.getMessage()); + } + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/RepositoryServiceImpl.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/RepositoryServiceImpl.java new file mode 100644 index 00000000..fb07cab7 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/impl/RepositoryServiceImpl.java @@ -0,0 +1,229 @@ +package com.github.mcp.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mcp.client.GitHubRestClient; +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreateBranchRequest; +import com.github.mcp.dto.request.CreateFileRequest; +import com.github.mcp.dto.request.CreateRepositoryRequest; +import com.github.mcp.dto.response.*; +import com.github.mcp.exception.GitHubMcpException; +import com.github.mcp.service.interfaces.RepositoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RepositoryServiceImpl implements RepositoryService { + + private final GitHubRestClient gitHubClient; + private final ObjectMapper objectMapper; + + @Override + public ApiResponse createRepository(CreateRepositoryRequest request) { + try { + String path = "/user/repos"; + RepositoryResponse response = gitHubClient.post(path, request, RepositoryResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error creating repository: {}", e.getMessage()); + return ApiResponse.error("CREATE_ERROR", "Failed to create repository: " + e.getMessage()); + } + } + + @Override + public ApiResponse forkRepository(String owner, String repo, String organization) { + try { + String path = String.format("/repos/%s/%s/forks", owner, repo); + Map body = new HashMap<>(); + if (organization != null) { + body.put("organization", organization); + } + RepositoryResponse response = gitHubClient.post(path, body, RepositoryResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error forking repository: {}", e.getMessage()); + return ApiResponse.error("FORK_ERROR", "Failed to fork repository: " + e.getMessage()); + } + } + + @Override + public ApiResponse getRepository(String owner, String repo) { + try { + String path = String.format("/repos/%s/%s", owner, repo); + RepositoryResponse response = gitHubClient.get(path, RepositoryResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error getting repository: {}", e.getMessage()); + return ApiResponse.error("GET_ERROR", "Failed to get repository: " + e.getMessage()); + } + } + + @Override + public ApiResponse> listCommits(String owner, String repo, String sha, String path, Integer page, Integer perPage) { + try { + String url = String.format("/repos/%s/%s/commits", owner, repo); + Map params = new HashMap<>(); + if (sha != null) params.put("sha", sha); + if (path != null) params.put("path", path); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse(url, params); + List commits = objectMapper.readerForListOf(CommitResponse.class) + .readValue(rawResponse.getBody()); + + return ApiResponse.success(PagedResponse.of(commits, page != null ? page : 1, perPage != null ? perPage : 30, commits.size())); + } catch (Exception e) { + log.error("Error listing commits: {}", e.getMessage()); + return ApiResponse.error("LIST_ERROR", "Failed to list commits: " + e.getMessage()); + } + } + + @Override + public ApiResponse getCommit(String owner, String repo, String ref) { + try { + String path = String.format("/repos/%s/%s/commits/%s", owner, repo, ref); + CommitResponse response = gitHubClient.get(path, CommitResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error getting commit: {}", e.getMessage()); + return ApiResponse.error("GET_ERROR", "Failed to get commit: " + e.getMessage()); + } + } + + @Override + public ApiResponse getFileContents(String owner, String repo, String path, String ref) { + try { + String url = String.format("/repos/%s/%s/contents/%s", owner, repo, path); + Map params = new HashMap<>(); + if (ref != null) params.put("ref", ref); + + FileContentResponse response = gitHubClient.get(url, params, FileContentResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error getting file contents: {}", e.getMessage()); + return ApiResponse.error("GET_ERROR", "Failed to get file contents: " + e.getMessage()); + } + } + + @Override + public ApiResponse> createOrUpdateFile(String owner, String repo, String path, CreateFileRequest request) { + try { + String url = String.format("/repos/%s/%s/contents/%s", owner, repo, path); + Map response = gitHubClient.put(url, request, Map.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error creating/updating file: {}", e.getMessage()); + return ApiResponse.error("UPDATE_ERROR", "Failed to create/update file: " + e.getMessage()); + } + } + + @Override + public ApiResponse deleteFile(String owner, String repo, String path, String message, String sha, String branch) { + try { + String url = String.format("/repos/%s/%s/contents/%s", owner, repo, path); + Map body = new HashMap<>(); + body.put("message", message); + body.put("sha", sha); + if (branch != null) body.put("branch", branch); + + gitHubClient.delete(url); + return ApiResponse.success(null); + } catch (Exception e) { + log.error("Error deleting file: {}", e.getMessage()); + return ApiResponse.error("DELETE_ERROR", "Failed to delete file: " + e.getMessage()); + } + } + + @Override + public ApiResponse createBranch(String owner, String repo, CreateBranchRequest request) { + try { + String path = String.format("/repos/%s/%s/git/refs", owner, repo); + Map body = new HashMap<>(); + body.put("ref", "refs/heads/" + request.getRef()); + body.put("sha", request.getSha()); + + BranchResponse response = gitHubClient.post(path, body, BranchResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error creating branch: {}", e.getMessage()); + return ApiResponse.error("CREATE_ERROR", "Failed to create branch: " + e.getMessage()); + } + } + + @Override + public ApiResponse> listBranches(String owner, String repo, Integer page, Integer perPage) { + try { + RepositoryCoordinates coordinates = normalizeRepositoryCoordinates(owner, repo); + String url = String.format("/repos/%s/%s/branches", coordinates.owner(), coordinates.repo()); + Map params = new HashMap<>(); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse(url, params); + List branches = objectMapper.readerForListOf(BranchResponse.class) + .readValue(rawResponse.getBody()); + + return ApiResponse.success(PagedResponse.of(branches, page != null ? page : 1, perPage != null ? perPage : 30, branches.size())); + } catch (Exception e) { + log.error("Error listing branches: {}", e.getMessage()); + return ApiResponse.error("LIST_ERROR", "Failed to list branches: " + e.getMessage()); + } + } + + private RepositoryCoordinates normalizeRepositoryCoordinates(String owner, String repo) { + if (!StringUtils.hasText(repo)) { + throw new GitHubMcpException("INVALID_REPOSITORY", "Repository name is required", 400); + } + + String trimmedRepo = repo.trim(); + if (trimmedRepo.contains("/")) { + String[] parts = trimmedRepo.split("/"); + if (parts.length == 2 && StringUtils.hasText(parts[0]) && StringUtils.hasText(parts[1])) { + if (!StringUtils.hasText(owner) || owner.trim().equals(parts[0])) { + return new RepositoryCoordinates(parts[0].trim(), parts[1].trim()); + } + } + throw new GitHubMcpException( + "INVALID_REPOSITORY", + "Repository must be provided either as repo or matching owner/repo pair", + 400); + } + + if (!StringUtils.hasText(owner)) { + throw new GitHubMcpException("INVALID_REPOSITORY", "Repository owner is required", 400); + } + + return new RepositoryCoordinates(owner.trim(), trimmedRepo); + } + + private record RepositoryCoordinates(String owner, String repo) { + } + + @Override + public ApiResponse> mergeBranch(String owner, String repo, String base, String head, String commitMessage) { + try { + String path = String.format("/repos/%s/%s/merges", owner, repo); + Map body = new HashMap<>(); + body.put("base", base); + body.put("head", head); + if (commitMessage != null) body.put("commit_message", commitMessage); + + Map response = gitHubClient.post(path, body, Map.class); + return ApiResponse.success(response); + } catch (Exception e) { + log.error("Error merging branch: {}", e.getMessage()); + return ApiResponse.error("MERGE_ERROR", "Failed to merge branch: " + e.getMessage()); + } + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/IssueService.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/IssueService.java new file mode 100644 index 00000000..ad496467 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/IssueService.java @@ -0,0 +1,33 @@ +package com.github.mcp.service.interfaces; + +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreateIssueRequest; +import com.github.mcp.dto.request.UpdateIssueRequest; +import com.github.mcp.dto.response.IssueResponse; + +import java.util.List; + +public interface IssueService { + ApiResponse createIssue(String owner, String repo, CreateIssueRequest request); + + ApiResponse updateIssue(String owner, String repo, Long issueNumber, UpdateIssueRequest request); + + ApiResponse addIssueComment(String owner, String repo, Long issueNumber, String body); + + ApiResponse> listIssues(String owner, String repo, String state, String labels, String assignee, Integer page, Integer perPage); + + ApiResponse getIssue(String owner, String repo, Long issueNumber); + + ApiResponse closeIssue(String owner, String repo, Long issueNumber); + + ApiResponse reopenIssue(String owner, String repo, Long issueNumber); + + ApiResponse assignIssue(String owner, String repo, Long issueNumber, List assignees); + + ApiResponse unassignIssue(String owner, String repo, Long issueNumber); + + ApiResponse addIssueLabels(String owner, String repo, Long issueNumber, List labels); + + ApiResponse removeIssueLabel(String owner, String repo, Long issueNumber, String label); +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/PullRequestService.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/PullRequestService.java new file mode 100644 index 00000000..cc33e964 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/PullRequestService.java @@ -0,0 +1,30 @@ +package com.github.mcp.service.interfaces; + +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreatePullRequestRequest; +import com.github.mcp.dto.request.MergePullRequestRequest; +import com.github.mcp.dto.request.UpdatePullRequestRequest; +import com.github.mcp.dto.response.PullRequestResponse; + +public interface PullRequestService { + ApiResponse createPullRequest(String owner, String repo, CreatePullRequestRequest request); + + ApiResponse updatePullRequest(String owner, String repo, Long pullNumber, UpdatePullRequestRequest request); + + ApiResponse> listPullRequests(String owner, String repo, String state, String head, String base, String sort, String direction, Integer page, Integer perPage); + + ApiResponse getPullRequest(String owner, String repo, Long pullNumber); + + ApiResponse mergePullRequest(String owner, String repo, Long pullNumber, MergePullRequestRequest request); + + ApiResponse closePullRequest(String owner, String repo, Long pullNumber); + + ApiResponse reopenPullRequest(String owner, String repo, Long pullNumber); + + ApiResponse addPullRequestComment(String owner, String repo, Long pullNumber, String body, String commitId, String path, Integer position); + + ApiResponse createPullRequestReview(String owner, String repo, Long pullNumber, String body, String event, Object comments); + + ApiResponse submitPullRequestReview(String owner, String repo, Long pullNumber, Long reviewId, String body, String event); +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/RepositoryService.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/RepositoryService.java new file mode 100644 index 00000000..b970b3a6 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/service/interfaces/RepositoryService.java @@ -0,0 +1,37 @@ +package com.github.mcp.service.interfaces; + +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreateBranchRequest; +import com.github.mcp.dto.request.CreateFileRequest; +import com.github.mcp.dto.request.CreateRepositoryRequest; +import com.github.mcp.dto.response.BranchResponse; +import com.github.mcp.dto.response.CommitResponse; +import com.github.mcp.dto.response.FileContentResponse; +import com.github.mcp.dto.response.RepositoryResponse; + +import java.util.Map; + +public interface RepositoryService { + ApiResponse createRepository(CreateRepositoryRequest request); + + ApiResponse forkRepository(String owner, String repo, String organization); + + ApiResponse getRepository(String owner, String repo); + + ApiResponse> listCommits(String owner, String repo, String sha, String path, Integer page, Integer perPage); + + ApiResponse getCommit(String owner, String repo, String ref); + + ApiResponse getFileContents(String owner, String repo, String path, String ref); + + ApiResponse> createOrUpdateFile(String owner, String repo, String path, CreateFileRequest request); + + ApiResponse deleteFile(String owner, String repo, String path, String message, String sha, String branch); + + ApiResponse createBranch(String owner, String repo, CreateBranchRequest request); + + ApiResponse> listBranches(String owner, String repo, Integer page, Integer perPage); + + ApiResponse> mergeBranch(String owner, String repo, String base, String head, String commitMessage); +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/IssueTools.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/IssueTools.java new file mode 100644 index 00000000..ce07e7b0 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/IssueTools.java @@ -0,0 +1,159 @@ +package com.github.mcp.tools; + +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreateIssueRequest; +import com.github.mcp.dto.request.UpdateIssueRequest; +import com.github.mcp.dto.response.IssueResponse; +import com.github.mcp.service.interfaces.IssueService; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class IssueTools { + + private final IssueService issueService; + + @McpTool(name = "create_issue", description = "Create a new issue in a GitHub repository") + public ApiResponse createIssue( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue title", required = true) String title, + @McpToolParam(description = "Issue body/description") String body, + @McpToolParam(description = "Comma-separated list of assignee usernames") String assignees, + @McpToolParam(description = "Comma-separated list of label names") String labels, + @McpToolParam(description = "Milestone number") Long milestone) { + + List assigneeList = assignees != null ? Arrays.asList(assignees.split(",")) : null; + List labelList = labels != null ? Arrays.asList(labels.split(",")) : null; + + CreateIssueRequest request = CreateIssueRequest.builder() + .title(title) + .body(body) + .assignees(assigneeList) + .labels(labelList) + .milestone(milestone) + .build(); + + return issueService.createIssue(owner, repo, request); + } + + @McpTool(name = "update_issue", description = "Update an existing issue in a GitHub repository") + public ApiResponse updateIssue( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber, + @McpToolParam(description = "New title") String title, + @McpToolParam(description = "New body/description") String body, + @McpToolParam(description = "Comma-separated list of label names") String labels, + @McpToolParam(description = "Milestone number") Long milestone) { + + List labelList = labels != null ? Arrays.asList(labels.split(",")) : null; + + UpdateIssueRequest request = UpdateIssueRequest.builder() + .title(title) + .body(body) + .labels(labelList) + .milestone(milestone) + .build(); + + return issueService.updateIssue(owner, repo, issueNumber, request); + } + + @McpTool(name = "add_issue_comment", description = "Add a comment to an existing issue") + public ApiResponse addIssueComment( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber, + @McpToolParam(description = "Comment body", required = true) String body) { + + return issueService.addIssueComment(owner, repo, issueNumber, body); + } + + @McpTool(name = "list_issues", description = "List issues in a GitHub repository") + public ApiResponse> listIssues( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue state: open, closed, all") String state, + @McpToolParam(description = "Comma-separated list of label names") String labels, + @McpToolParam(description = "Filter by assignee username") String assignee, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + return issueService.listIssues(owner, repo, state, labels, assignee, page, perPage); + } + + @McpTool(name = "get_issue", description = "Get details of a specific issue") + public ApiResponse getIssue( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber) { + + return issueService.getIssue(owner, repo, issueNumber); + } + + @McpTool(name = "close_issue", description = "Close an issue") + public ApiResponse closeIssue( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber) { + + return issueService.closeIssue(owner, repo, issueNumber); + } + + @McpTool(name = "reopen_issue", description = "Reopen a closed issue") + public ApiResponse reopenIssue( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber) { + + return issueService.reopenIssue(owner, repo, issueNumber); + } + + @McpTool(name = "assign_issue", description = "Assign an issue to users") + public ApiResponse assignIssue( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber, + @McpToolParam(description = "Comma-separated list of usernames to assign", required = true) String assignees) { + + List assigneeList = Arrays.asList(assignees.split(",")); + return issueService.assignIssue(owner, repo, issueNumber, assigneeList); + } + + @McpTool(name = "unassign_issue", description = "Remove all assignees from an issue") + public ApiResponse unassignIssue( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber) { + + return issueService.unassignIssue(owner, repo, issueNumber); + } + + @McpTool(name = "add_issue_labels", description = "Add labels to an issue") + public ApiResponse addIssueLabels( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber, + @McpToolParam(description = "Comma-separated list of label names", required = true) String labels) { + + List labelList = Arrays.asList(labels.split(",")); + return issueService.addIssueLabels(owner, repo, issueNumber, labelList); + } + + @McpTool(name = "remove_issue_label", description = "Remove a label from an issue") + public ApiResponse removeIssueLabel( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Issue number", required = true) Long issueNumber, + @McpToolParam(description = "Label name to remove", required = true) String label) { + + return issueService.removeIssueLabel(owner, repo, issueNumber, label); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/PullRequestTools.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/PullRequestTools.java new file mode 100644 index 00000000..e39e326e --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/PullRequestTools.java @@ -0,0 +1,157 @@ +package com.github.mcp.tools; + +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreatePullRequestRequest; +import com.github.mcp.dto.request.MergePullRequestRequest; +import com.github.mcp.dto.request.UpdatePullRequestRequest; +import com.github.mcp.dto.response.PullRequestResponse; +import com.github.mcp.service.interfaces.PullRequestService; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PullRequestTools { + + private final PullRequestService pullRequestService; + + @McpTool(name = "create_pull_request", description = "Create a new pull request") + public ApiResponse createPullRequest( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request title", required = true) String title, + @McpToolParam(description = "Pull request body/description") String body, + @McpToolParam(description = "Branch containing changes", required = true) String head, + @McpToolParam(description = "Branch to merge into", required = true) String base, + @McpToolParam(description = "Create as draft PR") Boolean draft) { + + CreatePullRequestRequest request = CreatePullRequestRequest.builder() + .title(title) + .body(body) + .head(head) + .base(base) + .draft(draft) + .build(); + + return pullRequestService.createPullRequest(owner, repo, request); + } + + @McpTool(name = "update_pull_request", description = "Update an existing pull request") + public ApiResponse updatePullRequest( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber, + @McpToolParam(description = "New title") String title, + @McpToolParam(description = "New body/description") String body, + @McpToolParam(description = "New base branch") String base) { + + UpdatePullRequestRequest request = UpdatePullRequestRequest.builder() + .title(title) + .body(body) + .base(base) + .build(); + + return pullRequestService.updatePullRequest(owner, repo, pullNumber, request); + } + + @McpTool(name = "list_pull_requests", description = "List pull requests in a repository") + public ApiResponse> listPullRequests( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "PR state: open, closed, all") String state, + @McpToolParam(description = "Filter by head branch (format: user:branch)") String head, + @McpToolParam(description = "Filter by base branch") String base, + @McpToolParam(description = "Sort field: created, updated, popularity, long-running") String sort, + @McpToolParam(description = "Sort direction: asc, desc") String direction, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + return pullRequestService.listPullRequests(owner, repo, state, head, base, sort, direction, page, perPage); + } + + @McpTool(name = "get_pull_request", description = "Get details of a specific pull request") + public ApiResponse getPullRequest( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber) { + + return pullRequestService.getPullRequest(owner, repo, pullNumber); + } + + @McpTool(name = "merge_pull_request", description = "Merge a pull request") + public ApiResponse mergePullRequest( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber, + @McpToolParam(description = "Commit title") String commitTitle, + @McpToolParam(description = "Commit message") String commitMessage, + @McpToolParam(description = "SHA that pull request head must match") String sha, + @McpToolParam(description = "Merge method: merge, squash, rebase") String mergeMethod) { + + MergePullRequestRequest request = MergePullRequestRequest.builder() + .commitTitle(commitTitle) + .commitMessage(commitMessage) + .sha(sha) + .mergeMethod(mergeMethod != null ? mergeMethod : "merge") + .build(); + + return pullRequestService.mergePullRequest(owner, repo, pullNumber, request); + } + + @McpTool(name = "close_pull_request", description = "Close a pull request without merging") + public ApiResponse closePullRequest( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber) { + + return pullRequestService.closePullRequest(owner, repo, pullNumber); + } + + @McpTool(name = "reopen_pull_request", description = "Reopen a closed pull request") + public ApiResponse reopenPullRequest( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber) { + + return pullRequestService.reopenPullRequest(owner, repo, pullNumber); + } + + @McpTool(name = "add_pull_request_comment", description = "Add a comment to a pull request") + public ApiResponse addPullRequestComment( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber, + @McpToolParam(description = "Comment body", required = true) String body, + @McpToolParam(description = "Commit SHA for review comment") String commitId, + @McpToolParam(description = "File path for review comment") String path, + @McpToolParam(description = "Line position for review comment") Integer position) { + + return pullRequestService.addPullRequestComment(owner, repo, pullNumber, body, commitId, path, position); + } + + @McpTool(name = "create_pull_request_review", description = "Create a review on a pull request") + public ApiResponse createPullRequestReview( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber, + @McpToolParam(description = "Review body") String body, + @McpToolParam(description = "Review event: APPROVE, REQUEST_CHANGES, COMMENT") String event) { + + return pullRequestService.createPullRequestReview(owner, repo, pullNumber, body, event, null); + } + + @McpTool(name = "submit_pull_request_review", description = "Submit a pending pull request review") + public ApiResponse submitPullRequestReview( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Pull request number", required = true) Long pullNumber, + @McpToolParam(description = "Review ID", required = true) Long reviewId, + @McpToolParam(description = "Review body") String body, + @McpToolParam(description = "Review event: APPROVE, REQUEST_CHANGES, COMMENT") String event) { + + return pullRequestService.submitPullRequestReview(owner, repo, pullNumber, reviewId, body, event); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/RepositoryTools.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/RepositoryTools.java new file mode 100644 index 00000000..87c10d1a --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/RepositoryTools.java @@ -0,0 +1,160 @@ +package com.github.mcp.tools; + +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.request.CreateBranchRequest; +import com.github.mcp.dto.request.CreateFileRequest; +import com.github.mcp.dto.request.CreateRepositoryRequest; +import com.github.mcp.dto.response.*; +import com.github.mcp.service.interfaces.RepositoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class RepositoryTools { + + private final RepositoryService repositoryService; + + @McpTool(name = "create_repository", description = "Create a new GitHub repository for the authenticated user") + public ApiResponse createRepository( + @McpToolParam(description = "Repository name", required = true) String name, + @McpToolParam(description = "Repository description") String description, + @McpToolParam(description = "Whether the repository should be private") Boolean isPrivate, + @McpToolParam(description = "Whether to initialize with a README") Boolean autoInit) { + + CreateRepositoryRequest request = CreateRepositoryRequest.builder() + .name(name) + .description(description) + .private_repo(isPrivate != null ? isPrivate : false) + .autoInit(autoInit != null ? autoInit : false) + .hasIssues(true) + .hasWiki(true) + .build(); + + return repositoryService.createRepository(request); + } + + @McpTool(name = "fork_repository", description = "Fork a GitHub repository to your account or specified organization") + public ApiResponse forkRepository( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Organization to fork to (optional, defaults to your account)") String organization) { + + return repositoryService.forkRepository(owner, repo, organization); + } + + @McpTool(name = "get_repository", description = "Get details of a specific GitHub repository") + public ApiResponse getRepository( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo) { + + return repositoryService.getRepository(owner, repo); + } + + @McpTool(name = "list_commits", description = "List commits in a GitHub repository") + public ApiResponse> listCommits( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Branch name or commit SHA") String sha, + @McpToolParam(description = "Path to filter commits by file") String path, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + return repositoryService.listCommits(owner, repo, sha, path, page, perPage); + } + + @McpTool(name = "get_commit", description = "Get details of a specific commit") + public ApiResponse getCommit( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Commit SHA or reference", required = true) String ref) { + + return repositoryService.getCommit(owner, repo, ref); + } + + @McpTool(name = "get_file_contents", description = "Get the contents of a file in a GitHub repository") + public ApiResponse getFileContents( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Path to the file", required = true) String path, + @McpToolParam(description = "Branch name, tag, or commit SHA") String ref) { + + return repositoryService.getFileContents(owner, repo, path, ref); + } + + @McpTool(name = "create_or_update_file", description = "Create or update a file in a GitHub repository") + public ApiResponse> createOrUpdateFile( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Path to the file", required = true) String path, + @McpToolParam(description = "Commit message", required = true) String message, + @McpToolParam(description = "File content (plain text)", required = true) String content, + @McpToolParam(description = "Branch name") String branch, + @McpToolParam(description = "SHA of the file being replaced (required for updates)") String sha) { + + String encodedContent = Base64.getEncoder().encodeToString(content.getBytes()); + + CreateFileRequest request = CreateFileRequest.builder() + .message(message) + .content(encodedContent) + .sha(sha) + .branch(branch) + .build(); + + return repositoryService.createOrUpdateFile(owner, repo, path, request); + } + + @McpTool(name = "delete_file", description = "Delete a file from a GitHub repository") + public ApiResponse deleteFile( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Path to the file", required = true) String path, + @McpToolParam(description = "Commit message", required = true) String message, + @McpToolParam(description = "SHA of the file to delete", required = true) String sha, + @McpToolParam(description = "Branch name") String branch) { + + return repositoryService.deleteFile(owner, repo, path, message, sha, branch); + } + + @McpTool(name = "create_branch", description = "Create a new branch in a GitHub repository") + public ApiResponse createBranch( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Name for the new branch", required = true) String branch, + @McpToolParam(description = "SHA of the commit to branch from", required = true) String fromSha) { + + CreateBranchRequest request = CreateBranchRequest.builder() + .ref(branch) + .sha(fromSha) + .build(); + + return repositoryService.createBranch(owner, repo, request); + } + + @McpTool(name = "list_branches", description = "List branches in a GitHub repository") + public ApiResponse> listBranches( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + return repositoryService.listBranches(owner, repo, page, perPage); + } + + @McpTool(name = "merge_branch", description = "Merge one branch into another in a GitHub repository") + public ApiResponse> mergeBranch( + @McpToolParam(description = "Repository owner", required = true) String owner, + @McpToolParam(description = "Repository name", required = true) String repo, + @McpToolParam(description = "Base branch to merge into", required = true) String base, + @McpToolParam(description = "Head branch to merge from", required = true) String head, + @McpToolParam(description = "Commit message for the merge") String commitMessage) { + + return repositoryService.mergeBranch(owner, repo, base, head, commitMessage); + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/SearchTools.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/SearchTools.java new file mode 100644 index 00000000..51822cbb --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/SearchTools.java @@ -0,0 +1,137 @@ +package com.github.mcp.tools; + +import com.github.mcp.client.GitHubRestClient; +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.response.*; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class SearchTools { + + private final GitHubRestClient gitHubClient; + + @McpTool(name = "search_code", description = "Search for code across GitHub repositories") + public ApiResponse> searchCode( + @McpToolParam(description = "Search query (see GitHub search syntax)", required = true) String query, + @McpToolParam(description = "Sort field: indexed") String sort, + @McpToolParam(description = "Sort order: asc, desc") String order, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + try { + Map params = new HashMap<>(); + params.put("q", query); + if (sort != null) params.put("sort", sort); + if (order != null) params.put("order", order); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse("/search/code", params); + SearchResultResponse result = + new com.fasterxml.jackson.databind.ObjectMapper().readValue( + rawResponse.getBody(), + new TypeReference>() {} + ); + + return ApiResponse.success(result); + } catch (Exception e) { + return ApiResponse.error("SEARCH_ERROR", "Failed to search code: " + e.getMessage()); + } + } + + @McpTool(name = "search_issues", description = "Search for issues and pull requests across GitHub") + public ApiResponse> searchIssues( + @McpToolParam(description = "Search query (see GitHub search syntax)", required = true) String query, + @McpToolParam(description = "Sort field: comments, reactions, created, updated, interactions") String sort, + @McpToolParam(description = "Sort order: asc, desc") String order, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + try { + Map params = new HashMap<>(); + params.put("q", query); + if (sort != null) params.put("sort", sort); + if (order != null) params.put("order", order); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse("/search/issues", params); + SearchResultResponse result = + new com.fasterxml.jackson.databind.ObjectMapper().readValue( + rawResponse.getBody(), + new TypeReference>() {} + ); + + return ApiResponse.success(result); + } catch (Exception e) { + return ApiResponse.error("SEARCH_ERROR", "Failed to search issues: " + e.getMessage()); + } + } + + @McpTool(name = "search_repositories", description = "Search for repositories on GitHub") + public ApiResponse> searchRepositories( + @McpToolParam(description = "Search query (see GitHub search syntax)", required = true) String query, + @McpToolParam(description = "Sort field: stars, forks, help-wanted-issues, updated") String sort, + @McpToolParam(description = "Sort order: asc, desc") String order, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + try { + Map params = new HashMap<>(); + params.put("q", query); + if (sort != null) params.put("sort", sort); + if (order != null) params.put("order", order); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse("/search/repositories", params); + SearchResultResponse result = + new com.fasterxml.jackson.databind.ObjectMapper().readValue( + rawResponse.getBody(), + new TypeReference>() {} + ); + + return ApiResponse.success(result); + } catch (Exception e) { + return ApiResponse.error("SEARCH_ERROR", "Failed to search repositories: " + e.getMessage()); + } + } + + @McpTool(name = "search_users", description = "Search for users on GitHub") + public ApiResponse> searchUsers( + @McpToolParam(description = "Search query (see GitHub search syntax)", required = true) String query, + @McpToolParam(description = "Sort field: followers, repositories, joined") String sort, + @McpToolParam(description = "Sort order: asc, desc") String order, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + + try { + Map params = new HashMap<>(); + params.put("q", query); + if (sort != null) params.put("sort", sort); + if (order != null) params.put("order", order); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse("/search/users", params); + SearchResultResponse result = + new com.fasterxml.jackson.databind.ObjectMapper().readValue( + rawResponse.getBody(), + new TypeReference>() {} + ); + + return ApiResponse.success(result); + } catch (Exception e) { + return ApiResponse.error("SEARCH_ERROR", "Failed to search users: " + e.getMessage()); + } + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/UserTools.java b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/UserTools.java new file mode 100644 index 00000000..bd9eed6d --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/java/com/github/mcp/tools/UserTools.java @@ -0,0 +1,73 @@ +package com.github.mcp.tools; + +import com.github.mcp.client.GitHubRestClient; +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.response.RepositoryResponse; +import com.github.mcp.dto.response.UserResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class UserTools { + + private final GitHubRestClient gitHubClient; + + @McpTool(name = "get_authenticated_user", description = "Get information about the authenticated user") + public ApiResponse getAuthenticatedUser() { + try { + UserResponse response = gitHubClient.get("/user", UserResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + return ApiResponse.error("GET_ERROR", "Failed to get user: " + e.getMessage()); + } + } + + @McpTool(name = "get_user", description = "Get information about a GitHub user") + public ApiResponse getUser( + @McpToolParam(description = "Username", required = true) String username) { + try { + String path = String.format("/users/%s", username); + UserResponse response = gitHubClient.get(path, UserResponse.class); + return ApiResponse.success(response); + } catch (Exception e) { + return ApiResponse.error("GET_ERROR", "Failed to get user: " + e.getMessage()); + } + } + + @McpTool(name = "list_user_repositories", description = "List repositories for a user") + public ApiResponse> listUserRepositories( + @McpToolParam(description = "Username (omit for authenticated user)") String username, + @McpToolParam(description = "Filter: all, owner, member") String type, + @McpToolParam(description = "Sort: created, updated, pushed, full_name") String sort, + @McpToolParam(description = "Direction: asc, desc") String direction, + @McpToolParam(description = "Page number") Integer page, + @McpToolParam(description = "Items per page (max 100)") Integer perPage) { + try { + String path = username != null ? String.format("/users/%s/repos", username) : "/user/repos"; + Map params = new HashMap<>(); + if (type != null) params.put("type", type); + if (sort != null) params.put("sort", sort); + if (direction != null) params.put("direction", direction); + params.put("page", page != null ? page : 1); + params.put("per_page", perPage != null ? perPage : 30); + + ResponseEntity rawResponse = gitHubClient.getRawResponse(path, params); + List repos = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(rawResponse.getBody(), new TypeReference>() {}); + + return ApiResponse.success(PagedResponse.of(repos, page != null ? page : 1, perPage != null ? perPage : 30, repos.size())); + } catch (Exception e) { + return ApiResponse.error("LIST_ERROR", "Failed to list repositories: " + e.getMessage()); + } + } +} diff --git a/model-context-protocol/github-mcp-server/src/main/resources/application.yml b/model-context-protocol/github-mcp-server/src/main/resources/application.yml new file mode 100644 index 00000000..d786e1b8 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/main/resources/application.yml @@ -0,0 +1,53 @@ +server: + port: 8081 + +spring: + application: + name: github-mcp-server + main: + banner-mode: off + ai: + mcp: + server: + enabled: true + name: github-mcp-server + version: 1.0.0 + type: SYNC + protocol: streamable + streamable-http: + mcp-endpoint: /mcp + annotation-scanner: + enabled: true + capabilities: + tool: true + +github: + personal-access-token: ${GITHUB_PERSONAL_ACCESS_TOKEN:} + host: ${GITHUB_HOST:github.com} + toolsets: ${GITHUB_TOOLSETS:} + tools: ${GITHUB_TOOLS:} + exclude-tools: ${GITHUB_EXCLUDE_TOOLS:} + features: ${GITHUB_FEATURES:} + dynamic-toolsets: ${GITHUB_DYNAMIC_TOOLSETS:false} + read-only: ${GITHUB_READ_ONLY:false} + log-file: ${GITHUB_LOG_FILE:} + enable-command-logging: ${GITHUB_ENABLE_COMMAND_LOGGING:false} + export-translations: ${GITHUB_EXPORT_TRANSLATIONS:false} + content-window-size: ${GITHUB_CONTENT_WINDOW_SIZE:5000} + lockdown-mode: ${GITHUB_LOCKDOWN_MODE:false} + insiders: ${GITHUB_INSIDERS:false} + repo-access-cache-ttl: ${GITHUB_REPO_ACCESS_CACHE_TTL:5m} + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: always + +logging: + level: + root: INFO + com.github.mcp: DEBUG diff --git a/model-context-protocol/github-mcp-server/src/test/java/com/github/mcp/client/GitHubRestClientTest.java b/model-context-protocol/github-mcp-server/src/test/java/com/github/mcp/client/GitHubRestClientTest.java new file mode 100644 index 00000000..d7bc84cf --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/test/java/com/github/mcp/client/GitHubRestClientTest.java @@ -0,0 +1,95 @@ +package com.github.mcp.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mcp.config.GitHubProperties; +import com.github.mcp.config.WebConfig; +import com.github.mcp.dto.response.PullRequestResponse; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class GitHubRestClientTest { + + @Test + void getShouldDeserializePullRequestResponseWithNestedRepositoryTopics() { + WebConfig webConfig = new WebConfig(); + ObjectMapper objectMapper = webConfig.objectMapper(); + RestTemplate restTemplate = webConfig.restTemplate(); + + GitHubProperties properties = new GitHubProperties(); + properties.setHost("github.com"); + properties.setPersonalAccessToken("test-token"); + + GitHubRestClient client = new GitHubRestClient(restTemplate, properties, objectMapper); + client.init(); + + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + server.expect(requestTo("https://api.github.com/repos/octocat/hello-world/pulls/1")) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess(""" + { + "id": 101, + "number": 1, + "title": "Add feature", + "state": "open", + "html_url": "https://github.com/octocat/hello-world/pull/1", + "head": { + "label": "octocat:feature", + "ref": "feature", + "sha": "abc123", + "repo": { + "id": 200, + "name": "hello-world", + "full_name": "octocat/hello-world", + "private": false, + "default_branch": "main", + "topics": ["java", "spring"] + }, + "user": { + "login": "octocat", + "id": 1 + } + }, + "base": { + "label": "octocat:main", + "ref": "main", + "sha": "def456", + "repo": { + "id": 200, + "name": "hello-world", + "full_name": "octocat/hello-world", + "private": false, + "default_branch": "main", + "topics": ["java", "spring"] + }, + "user": { + "login": "octocat", + "id": 1 + } + }, + "created_at": "2026-03-30T10:00:00Z", + "updated_at": "2026-03-30T11:00:00Z" + } + """, MediaType.APPLICATION_JSON)); + + PullRequestResponse response = client.get("/repos/octocat/hello-world/pulls/1", PullRequestResponse.class); + + assertThat(response).isNotNull(); + assertThat(response.getNumber()).isEqualTo(1L); + assertThat(response.getHead()).isNotNull(); + assertThat(response.getHead().getRepo()).isNotNull(); + assertThat(response.getHead().getRepo().getFullName()).isEqualTo("octocat/hello-world"); + assertThat(response.getHead().getRepo().getTopics()).containsExactly("java", "spring"); + assertThat(response.getBase().getRepo().getTopics()).containsExactly("java", "spring"); + + server.verify(); + } +} + diff --git a/model-context-protocol/github-mcp-server/src/test/java/com/github/mcp/service/impl/RepositoryServiceImplTest.java b/model-context-protocol/github-mcp-server/src/test/java/com/github/mcp/service/impl/RepositoryServiceImplTest.java new file mode 100644 index 00000000..0316f642 --- /dev/null +++ b/model-context-protocol/github-mcp-server/src/test/java/com/github/mcp/service/impl/RepositoryServiceImplTest.java @@ -0,0 +1,74 @@ +package com.github.mcp.service.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.mcp.client.GitHubRestClient; +import com.github.mcp.config.WebConfig; +import com.github.mcp.dto.common.ApiResponse; +import com.github.mcp.dto.common.PagedResponse; +import com.github.mcp.dto.response.BranchResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RepositoryServiceImplTest { + + @Mock + private GitHubRestClient gitHubClient; + + private RepositoryServiceImpl repositoryService; + + @BeforeEach + void setUp() { + ObjectMapper objectMapper = new WebConfig().objectMapper(); + repositoryService = new RepositoryServiceImpl(gitHubClient, objectMapper); + } + + @Test + void listBranchesShouldNormalizeOwnerAndRepoWhenRepoContainsFullName() { + when(gitHubClient.getRawResponse(anyString(), anyMap())) + .thenReturn(ResponseEntity.ok(""" + [ + { + "name": "main", + "protected": true, + "commit": { + "sha": "abc123", + "url": "https://api.github.com/repos/parthloglogn/free-shorts-app/commits/abc123" + } + } + ] + """)); + + ApiResponse> response = repositoryService.listBranches( + "parthloglogn", + "parthloglogn/free-shorts-app", + 1, + 30); + + ArgumentCaptor pathCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor> paramsCaptor = ArgumentCaptor.forClass((Class) Map.class); + verify(gitHubClient).getRawResponse(pathCaptor.capture(), paramsCaptor.capture()); + + assertThat(pathCaptor.getValue()).isEqualTo("/repos/parthloglogn/free-shorts-app/branches"); + assertThat(paramsCaptor.getValue()).containsEntry("page", 1).containsEntry("per_page", 30); + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isNotNull(); + assertThat(response.getData().getItems()).hasSize(1); + assertThat(response.getData().getItems().getFirst().getName()).isEqualTo("main"); + assertThat(response.getData().getItems().getFirst().getProtected_branch()).isTrue(); + } +} +