✨ (mcp): Add issue comment tools (listIssueComments, addIssueComment)
Changes
4 files changed, +81 -3
MODIFY
docs/users/mcp.md
+2 -1
@@ -70,8 +70,9 @@
70
70
|---|---|---|
71
71
| Repositories | `listRepositories`, `getRepository` | no (public repos) |
72
72
| | `createRepository`, `forkRepository`, `deleteRepository` | yes |
73
-| Issues | `listIssues`, `getIssue` | no (public repos) |
73
+| Issues | `listIssues`, `getIssue`, `listIssueComments` | no (public repos) |
74
74
| | `createIssue`, `updateIssueStatus`, `deleteIssue` | yes (owner or collaborator) |
75
+| | `addIssueComment` | yes (any reader of the repository) |
75
76
| Merge requests | `listMergeRequests`, `getMergeRequest`, `listMergeRequestComments` | no (public repos) |
76
77
| | `createMergeRequest`, `mergeMergeRequest`, `closeMergeRequest` | yes (owner or collaborator) |
77
78
| | `addMergeRequestComment` | yes (any reader of the repository) |
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+10 -0
@@ -4,6 +4,7 @@
4
4
import java.util.List;
5
5
6
6
import de.workaround.model.Issue;
7
+import de.workaround.model.IssueComment;
7
8
import de.workaround.model.MergeRequest;
8
9
import de.workaround.model.MergeRequestComment;
9
10
import de.workaround.model.Repository;
@@ -75,6 +76,15 @@
75
76
}
76
77
}
77
78
79
+ public record IssueCommentView(String id, String body, String author, Instant createdAt)
80
+ {
81
+ public static IssueCommentView of(IssueComment comment)
82
+ {
83
+ return new IssueCommentView(comment.id.toString(), comment.body, comment.author.username,
84
+ comment.createdAt);
85
+ }
86
+ }
87
+
78
88
public record UserView(String username, String displayName)
79
89
{
80
90
}
MODIFY
src/main/java/de/workaround/mcp/IssueTools.java
+30 -2
@@ -3,8 +3,10 @@
3
3
import java.util.List;
4
4
5
5
import de.workaround.api.ApiModels;
6
+import de.workaround.git.IssueCommentService;
6
7
import de.workaround.git.IssueService;
7
8
import de.workaround.model.Issue;
9
+import de.workaround.model.IssueComment;
8
10
import de.workaround.model.Repository;
9
11
import de.workaround.model.User;
10
12
import io.quarkiverse.mcp.server.Tool;
@@ -15,8 +17,9 @@
15
17
import jakarta.transaction.Transactional;
16
18
17
19
/**
18
- * MCP tools mirroring the issue REST endpoints. Issues are addressed by their per-repository number.
19
- * Reading follows repository visibility; creating, transitioning and deleting require a token and ownership.
20
+ * MCP tools mirroring the issue REST endpoints, including free-text discussion comments. Issues are
21
+ * addressed by their per-repository number. Reading follows repository visibility; creating, transitioning
22
+ * and deleting require a token and ownership, while commenting only requires read access.
20
23
*/
21
24
@Singleton
22
25
public class IssueTools
@@ -25,6 +28,9 @@
25
28
IssueService issues;
26
29
27
30
@Inject
31
+ IssueCommentService comments;
32
+
33
+ @Inject
28
34
McpRepoAccess access;
29
35
30
36
@Inject
@@ -83,6 +89,28 @@
83
89
return "Deleted issue #" + number;
84
90
}
85
91
92
+ @Tool(description = "List the discussion comments of an issue.")
93
+ public List<ApiModels.IssueCommentView> listIssueComments(@ToolArg(description = "Owner username") String owner,
94
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Issue number") int number)
95
+ {
96
+ Repository repo = access.requireReadable(principal.orNull(), owner, name);
97
+ Issue issue = requireIssue(repo, number);
98
+ return comments.list(issue).stream().map(ApiModels.IssueCommentView::of).toList();
99
+ }
100
+
101
+ @Tool(description = "Add a discussion comment to an issue. Requires a token; any reader may comment.")
102
+ @Transactional
103
+ public ApiModels.IssueCommentView addIssueComment(@ToolArg(description = "Owner username") String owner,
104
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Issue number") int number,
105
+ @ToolArg(description = "Comment body") String body)
106
+ {
107
+ User user = principal.require();
108
+ Repository repo = access.requireReadable(user, owner, name);
109
+ Issue issue = requireIssue(repo, number);
110
+ IssueComment comment = comments.add(user, issue, body);
111
+ return ApiModels.IssueCommentView.of(comment);
112
+ }
113
+
86
114
private Issue requireIssue(Repository repo, int number)
87
115
{
88
116
return issues.find(repo, number)
MODIFY
src/test/java/de/workaround/mcp/McpToolsTest.java
+39 -0
@@ -6,7 +6,9 @@
6
6
import org.junit.jupiter.api.Test;
7
7
8
8
import de.workaround.git.GitRepositoryService;
9
+import de.workaround.git.IssueService;
9
10
import de.workaround.http.AccessTokenService;
11
+import de.workaround.model.Issue;
10
12
import de.workaround.model.Repository;
11
13
import de.workaround.model.User;
12
14
import io.quarkiverse.mcp.server.test.McpAssured;
@@ -30,6 +32,9 @@
30
32
AccessTokenService tokenService;
31
33
32
34
@Inject
35
+ IssueService issueService;
36
+
37
+ @Inject
33
38
User.Repo userRepo;
34
39
35
40
@Test
@@ -120,6 +125,40 @@
120
125
.thenAssertResults();
121
126
}
122
127
128
+ @Test
129
+ void addAndListIssueCommentTools()
130
+ {
131
+ User owner = persistUser("mcp-icmt-" + shortId());
132
+ Repository repo = service.create(owner, "mcpissuecomments", Repository.Visibility.PUBLIC, null);
133
+ int issueNumber = createIssue(owner, repo, "needs discussion");
134
+ String token = tokenService.create(owner, "mcp").plaintext();
135
+
136
+ McpStreamableTestClient client = McpAssured.newStreamableClient()
137
+ .setMcpPath("/mcp")
138
+ .setAdditionalHeaders(json -> MultiMap.caseInsensitiveMultiMap().add("Authorization", "Bearer " + token))
139
+ .build().connect();
140
+
141
+ client.when()
142
+ .toolsCall("addIssueComment", Map.of("owner", owner.username, "name", "mcpissuecomments",
143
+ "number", issueNumber, "body", "first thought"), response -> {
144
+ assertFalse(response.isError());
145
+ assertTrue(response.firstContent().asText().text().contains("first thought"));
146
+ })
147
+ .toolsCall("listIssueComments", Map.of("owner", owner.username, "name", "mcpissuecomments",
148
+ "number", issueNumber), response -> {
149
+ assertFalse(response.isError());
150
+ assertTrue(response.firstContent().asText().text().contains("first thought"));
151
+ })
152
+ .thenAssertResults();
153
+ }
154
+
155
+ @Transactional
156
+ int createIssue(User owner, Repository repo, String title)
157
+ {
158
+ Issue issue = issueService.create(owner, repo, title, null);
159
+ return issue.number;
160
+ }
161
+
123
162
private static String shortId()
124
163
{
125
164
return UUID.randomUUID().toString().substring(0, 8);