✨ (mcp): Expose features as MCP tools over Streamable HTTP
Changes
10 files changed, +600 -4
MODIFY
README.md
+22 -0
@@ -27,6 +27,9 @@
27
27
- Single access policy on all paths: owner read/write, public world-readable, private owner-only
28
28
- **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
29
29
git-over-HTTP (`Authorization: Bearer <token>`), auto-documented via OpenAPI/Swagger UI (see below)
30
+- **MCP server** at `/mcp` (Streamable HTTP), exposing the same feature set as the REST API as
31
+ MCP tools so an AI client can manage repositories, issues, merge requests, and MR line-comments
32
+ (see below)
30
33
- **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
31
34
exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
32
35
activities from (see below)
@@ -84,6 +87,25 @@
84
87
| GET, POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments` | List / add line-level review comments (any reader may comment) |
85
88
| DELETE | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId}` | Delete a comment (author or repo owner) |
86
89
90
+## MCP server
91
+
92
+An MCP (Model Context Protocol) server is exposed via the Quarkiverse extension
93
+`io.quarkiverse.mcp:quarkus-mcp-server-http` (1.13.1), mirroring the REST API's feature set as
94
+MCP tools.
95
+
96
+- **Transport:** Streamable HTTP at `/mcp` (the extension also serves the legacy HTTP/SSE
97
+ variant, but Streamable HTTP is the one git-shark uses)
98
+- **Auth:** same personal access tokens as the REST API, sent as `Authorization: Bearer <token>`
99
+ on the MCP request; read tools work anonymously for public repos, write tools require a token
100
+ and repository ownership (commenting only requires read access)
101
+
102
+| Area | Tools |
103
+|---|---|
104
+| Repositories | `listRepositories`, `getRepository`, `createRepository`, `deleteRepository` |
105
+| Issues | `listIssues`, `getIssue`, `createIssue`, `updateIssueStatus`, `deleteIssue` |
106
+| Merge requests | `listMergeRequests`, `getMergeRequest`, `createMergeRequest`, `mergeMergeRequest`, `closeMergeRequest`, `listMergeRequestComments`, `addMergeRequestComment` |
107
+| User | `currentUser` |
108
+
87
109
## Architecture notes
88
110
89
111
- Repository names resolve to UUID-based storage paths **through the database only**
MODIFY
pom.xml
+12 -0
@@ -19,6 +19,7 @@
19
19
<sshd.version>2.18.0</sshd.version>
20
20
<bouncycastle.version>1.84</bouncycastle.version>
21
21
<commonmark.version>0.24.0</commonmark.version>
22
+ <quarkus-mcp-server.version>1.13.1</quarkus-mcp-server.version>
22
23
<skipITs>true</skipITs>
23
24
<surefire-plugin.version>3.5.6</surefire-plugin.version>
24
25
</properties>
@@ -57,6 +58,11 @@
57
58
<artifactId>quarkus-rest-jackson</artifactId>
58
59
</dependency>
59
60
<dependency>
61
+ <groupId>io.quarkiverse.mcp</groupId>
62
+ <artifactId>quarkus-mcp-server-http</artifactId>
63
+ <version>${quarkus-mcp-server.version}</version>
64
+ </dependency>
65
+ <dependency>
60
66
<groupId>io.quarkus</groupId>
61
67
<artifactId>quarkus-hibernate-panache-next</artifactId>
62
68
</dependency>
@@ -144,6 +150,12 @@
144
150
<artifactId>rest-assured</artifactId>
145
151
<scope>test</scope>
146
152
</dependency>
153
+ <dependency>
154
+ <groupId>io.quarkiverse.mcp</groupId>
155
+ <artifactId>quarkus-mcp-server-test</artifactId>
156
+ <version>${quarkus-mcp-server.version}</version>
157
+ <scope>test</scope>
158
+ </dependency>
147
159
</dependencies>
148
160
149
161
<build>
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+4 -4
@@ -23,7 +23,7 @@
23
23
public record RepositoryView(String owner, String name, Repository.Visibility visibility, String description,
24
24
Instant createdAt)
25
25
{
26
- static RepositoryView of(Repository repo)
26
+ public static RepositoryView of(Repository repo)
27
27
{
28
28
return new RepositoryView(repo.owner.username, repo.name, repo.visibility, repo.description,
29
29
repo.createdAt);
@@ -33,7 +33,7 @@
33
33
public record IssueView(int number, String title, String description, Issue.Status status, String author,
34
34
Instant createdAt)
35
35
{
36
- static IssueView of(Issue issue)
36
+ public static IssueView of(Issue issue)
37
37
{
38
38
return new IssueView(issue.number, issue.title, issue.description, issue.status,
39
39
issue.author.username, issue.createdAt);
@@ -43,7 +43,7 @@
43
43
public record MergeRequestView(int number, String title, String description, String sourceBranch,
44
44
String targetBranch, MergeRequest.Status status, String author, Instant createdAt, Instant mergedAt)
45
45
{
46
- static MergeRequestView of(MergeRequest mr)
46
+ public static MergeRequestView of(MergeRequest mr)
47
47
{
48
48
return new MergeRequestView(mr.number, mr.title, mr.description, mr.sourceBranch, mr.targetBranch,
49
49
mr.status, mr.author.username, mr.createdAt, mr.mergedAt);
@@ -53,7 +53,7 @@
53
53
public record CommentView(String id, String filePath, int oldLine, int newLine, String body, String author,
54
54
Instant createdAt)
55
55
{
56
- static CommentView of(MergeRequestComment comment)
56
+ public static CommentView of(MergeRequestComment comment)
57
57
{
58
58
return new CommentView(comment.id.toString(), comment.filePath, comment.oldLine, comment.newLine,
59
59
comment.body, comment.author.username, comment.createdAt);
ADD
src/main/java/de/workaround/mcp/IssueTools.java
+103 -0
@@ -0,0 +1,103 @@
1
+package de.workaround.mcp;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.api.ApiModels;
6
+import de.workaround.git.IssueService;
7
+import de.workaround.model.Issue;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import io.quarkiverse.mcp.server.Tool;
11
+import io.quarkiverse.mcp.server.ToolArg;
12
+import io.quarkiverse.mcp.server.ToolCallException;
13
+import jakarta.inject.Inject;
14
+import jakarta.inject.Singleton;
15
+import jakarta.transaction.Transactional;
16
+
17
+/**
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
+ */
21
+@Singleton
22
+public class IssueTools
23
+{
24
+ @Inject
25
+ IssueService issues;
26
+
27
+ @Inject
28
+ McpRepoAccess access;
29
+
30
+ @Inject
31
+ McpPrincipal principal;
32
+
33
+ @Tool(description = "List the issues of a repository.")
34
+ public List<ApiModels.IssueView> listIssues(@ToolArg(description = "Owner username") String owner,
35
+ @ToolArg(description = "Repository name") String name)
36
+ {
37
+ Repository repo = access.requireReadable(principal.orNull(), owner, name);
38
+ return issues.list(repo).stream().map(ApiModels.IssueView::of).toList();
39
+ }
40
+
41
+ @Tool(description = "Get a single issue by its number.")
42
+ public ApiModels.IssueView getIssue(@ToolArg(description = "Owner username") String owner,
43
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Issue number") int number)
44
+ {
45
+ Repository repo = access.requireReadable(principal.orNull(), owner, name);
46
+ return ApiModels.IssueView.of(requireIssue(repo, number));
47
+ }
48
+
49
+ @Tool(description = "Create an issue in a repository. Requires a personal access token and repository ownership.")
50
+ @Transactional
51
+ public ApiModels.IssueView createIssue(@ToolArg(description = "Owner username") String owner,
52
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Issue title") String title,
53
+ @ToolArg(description = "Optional description", defaultValue = "") String description)
54
+ {
55
+ User user = principal.require();
56
+ Repository repo = access.requireReadable(user, owner, name);
57
+ Issue issue = issues.create(user, repo, title, description == null || description.isBlank() ? null : description);
58
+ return ApiModels.IssueView.of(issue);
59
+ }
60
+
61
+ @Tool(description = "Update an issue's status. Requires a token and ownership.")
62
+ @Transactional
63
+ public ApiModels.IssueView updateIssueStatus(@ToolArg(description = "Owner username") String owner,
64
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Issue number") int number,
65
+ @ToolArg(description = "New status: PLANNED, IN_DEVELOPMENT or DONE") String status)
66
+ {
67
+ User user = principal.require();
68
+ Repository repo = access.requireReadable(user, owner, name);
69
+ Issue issue = requireIssue(repo, number);
70
+ issues.updateStatus(user, issue, parseStatus(status));
71
+ return ApiModels.IssueView.of(requireIssue(repo, number));
72
+ }
73
+
74
+ @Tool(description = "Delete an issue by its number. Requires a token and ownership.")
75
+ @Transactional
76
+ public String deleteIssue(@ToolArg(description = "Owner username") String owner,
77
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Issue number") int number)
78
+ {
79
+ User user = principal.require();
80
+ Repository repo = access.requireReadable(user, owner, name);
81
+ Issue issue = requireIssue(repo, number);
82
+ issues.delete(user, issue);
83
+ return "Deleted issue #" + number;
84
+ }
85
+
86
+ private Issue requireIssue(Repository repo, int number)
87
+ {
88
+ return issues.find(repo, number)
89
+ .orElseThrow(() -> new ToolCallException("Issue #" + number + " not found"));
90
+ }
91
+
92
+ private static Issue.Status parseStatus(String status)
93
+ {
94
+ try
95
+ {
96
+ return Issue.Status.valueOf(status.trim().toUpperCase());
97
+ }
98
+ catch (IllegalArgumentException | NullPointerException e)
99
+ {
100
+ throw new ToolCallException("Invalid status: " + status + " (expected PLANNED, IN_DEVELOPMENT or DONE)");
101
+ }
102
+ }
103
+}
ADD
src/main/java/de/workaround/mcp/McpPrincipal.java
+61 -0
@@ -0,0 +1,61 @@
1
+package de.workaround.mcp;
2
+
3
+import de.workaround.http.AccessTokenService;
4
+import de.workaround.model.User;
5
+import io.quarkiverse.mcp.server.ToolCallException;
6
+import io.vertx.core.http.HttpServerRequest;
7
+import jakarta.enterprise.context.RequestScoped;
8
+import jakarta.inject.Inject;
9
+
10
+/**
11
+ * Resolves the user an MCP tool call acts as, from the {@code Authorization: Bearer <token>} header of the
12
+ * Streamable-HTTP request, where the token is a personal access token (the same secret used for the REST API
13
+ * and Git-over-HTTP). Mirrors the REST {@code ApiPrincipal}: tools apply the same read/write authorization
14
+ * rules as the UI and REST surface. Request-scoped, so it is only usable from HTTP-transport tool calls.
15
+ */
16
+@RequestScoped
17
+public class McpPrincipal
18
+{
19
+ private static final String BEARER = "Bearer ";
20
+
21
+ @Inject
22
+ HttpServerRequest request;
23
+
24
+ @Inject
25
+ AccessTokenService tokens;
26
+
27
+ private boolean resolved;
28
+ private User user;
29
+
30
+ /** The authenticated user, or {@code null} when the call carried no valid token. */
31
+ public User orNull()
32
+ {
33
+ if (!resolved)
34
+ {
35
+ user = resolve();
36
+ resolved = true;
37
+ }
38
+ return user;
39
+ }
40
+
41
+ /** The authenticated user, or a {@link ToolCallException} (failed tool response) when no valid token was sent. */
42
+ public User require()
43
+ {
44
+ User current = orNull();
45
+ if (current == null)
46
+ {
47
+ throw new ToolCallException("A valid personal access token is required (Authorization: Bearer)");
48
+ }
49
+ return current;
50
+ }
51
+
52
+ private User resolve()
53
+ {
54
+ String header = request.getHeader("Authorization");
55
+ if (header == null || !header.regionMatches(true, 0, BEARER, 0, BEARER.length()))
56
+ {
57
+ return null;
58
+ }
59
+ return tokens.authenticate(header.substring(BEARER.length()).trim()).orElse(null);
60
+ }
61
+}
ADD
src/main/java/de/workaround/mcp/McpRepoAccess.java
+36 -0
@@ -0,0 +1,36 @@
1
+package de.workaround.mcp;
2
+
3
+import de.workaround.git.AccessPolicy;
4
+import de.workaround.git.GitRepositoryService;
5
+import de.workaround.model.Repository;
6
+import de.workaround.model.User;
7
+import io.quarkiverse.mcp.server.ToolCallException;
8
+import jakarta.enterprise.context.ApplicationScoped;
9
+import jakarta.inject.Inject;
10
+
11
+/**
12
+ * Shared repository resolution for MCP tools: looks a repository up by owner and name and enforces the
13
+ * read-visibility rule (public repos are world-readable, private ones owner-only). A missing or unreadable
14
+ * repository is reported identically as "not found", so private repositories are never revealed to callers
15
+ * who cannot read them.
16
+ */
17
+@ApplicationScoped
18
+class McpRepoAccess
19
+{
20
+ @Inject
21
+ GitRepositoryService repositories;
22
+
23
+ @Inject
24
+ AccessPolicy accessPolicy;
25
+
26
+ Repository requireReadable(User actor, String owner, String name)
27
+ {
28
+ Repository repo = repositories.find(owner, name)
29
+ .orElseThrow(() -> new ToolCallException("Repository not found: " + owner + "/" + name));
30
+ if (!accessPolicy.canRead(actor, repo))
31
+ {
32
+ throw new ToolCallException("Repository not found: " + owner + "/" + name);
33
+ }
34
+ return repo;
35
+ }
36
+}
ADD
src/main/java/de/workaround/mcp/MergeRequestTools.java
+126 -0
@@ -0,0 +1,126 @@
1
+package de.workaround.mcp;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.api.ApiModels;
6
+import de.workaround.git.MergeRequestCommentService;
7
+import de.workaround.git.MergeRequestService;
8
+import de.workaround.model.MergeRequest;
9
+import de.workaround.model.MergeRequestComment;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import io.quarkiverse.mcp.server.Tool;
13
+import io.quarkiverse.mcp.server.ToolArg;
14
+import io.quarkiverse.mcp.server.ToolCallException;
15
+import jakarta.inject.Inject;
16
+import jakarta.inject.Singleton;
17
+import jakarta.transaction.Transactional;
18
+
19
+/**
20
+ * MCP tools mirroring the merge-request REST endpoints, including line-level review comments. Merge requests
21
+ * are addressed by their per-repository number. Reading follows repository visibility; creating, merging and
22
+ * closing require a token and ownership, while commenting only requires read access.
23
+ */
24
+@Singleton
25
+public class MergeRequestTools
26
+{
27
+ @Inject
28
+ MergeRequestService mergeRequests;
29
+
30
+ @Inject
31
+ MergeRequestCommentService comments;
32
+
33
+ @Inject
34
+ McpRepoAccess access;
35
+
36
+ @Inject
37
+ McpPrincipal principal;
38
+
39
+ @Tool(description = "List the merge requests of a repository.")
40
+ public List<ApiModels.MergeRequestView> listMergeRequests(@ToolArg(description = "Owner username") String owner,
41
+ @ToolArg(description = "Repository name") String name)
42
+ {
43
+ Repository repo = access.requireReadable(principal.orNull(), owner, name);
44
+ return mergeRequests.list(repo).stream().map(ApiModels.MergeRequestView::of).toList();
45
+ }
46
+
47
+ @Tool(description = "Get a single merge request by its number.")
48
+ public ApiModels.MergeRequestView getMergeRequest(@ToolArg(description = "Owner username") String owner,
49
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number)
50
+ {
51
+ Repository repo = access.requireReadable(principal.orNull(), owner, name);
52
+ return ApiModels.MergeRequestView.of(requireMergeRequest(repo, number));
53
+ }
54
+
55
+ @Tool(description = "Open a merge request from a source branch into a target branch. Requires a token and ownership.")
56
+ @Transactional
57
+ public ApiModels.MergeRequestView createMergeRequest(@ToolArg(description = "Owner username") String owner,
58
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Title") String title,
59
+ @ToolArg(description = "Optional description", defaultValue = "") String description,
60
+ @ToolArg(description = "Source branch") String sourceBranch,
61
+ @ToolArg(description = "Target branch") String targetBranch)
62
+ {
63
+ User user = principal.require();
64
+ Repository repo = access.requireReadable(user, owner, name);
65
+ MergeRequest mr = mergeRequests.create(user, repo, title,
66
+ description == null || description.isBlank() ? null : description, sourceBranch, targetBranch);
67
+ return ApiModels.MergeRequestView.of(mr);
68
+ }
69
+
70
+ @Tool(description = "Merge an open merge request. Requires a token and ownership.")
71
+ @Transactional
72
+ public ApiModels.MergeRequestView mergeMergeRequest(@ToolArg(description = "Owner username") String owner,
73
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number)
74
+ {
75
+ User user = principal.require();
76
+ Repository repo = access.requireReadable(user, owner, name);
77
+ MergeRequest mr = requireMergeRequest(repo, number);
78
+ mergeRequests.merge(user, mr);
79
+ return ApiModels.MergeRequestView.of(requireMergeRequest(repo, number));
80
+ }
81
+
82
+ @Tool(description = "Close an open merge request without merging. Requires a token and ownership.")
83
+ @Transactional
84
+ public ApiModels.MergeRequestView closeMergeRequest(@ToolArg(description = "Owner username") String owner,
85
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number)
86
+ {
87
+ User user = principal.require();
88
+ Repository repo = access.requireReadable(user, owner, name);
89
+ MergeRequest mr = requireMergeRequest(repo, number);
90
+ mergeRequests.close(user, mr);
91
+ return ApiModels.MergeRequestView.of(requireMergeRequest(repo, number));
92
+ }
93
+
94
+ @Tool(description = "List the line-level review comments of a merge request.")
95
+ public List<ApiModels.CommentView> listMergeRequestComments(@ToolArg(description = "Owner username") String owner,
96
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number)
97
+ {
98
+ Repository repo = access.requireReadable(principal.orNull(), owner, name);
99
+ MergeRequest mr = requireMergeRequest(repo, number);
100
+ return comments.list(mr).stream().map(ApiModels.CommentView::of).toList();
101
+ }
102
+
103
+ @Tool(description = "Add a line-level review comment to a merge request's diff. Requires a token; any reader may comment. "
104
+ + "The comment must anchor to a line present in the diff: use -1 for the side a line does not exist on "
105
+ + "(added lines have no old-side number, deleted lines no new-side number).")
106
+ @Transactional
107
+ public ApiModels.CommentView addMergeRequestComment(@ToolArg(description = "Owner username") String owner,
108
+ @ToolArg(description = "Repository name") String name, @ToolArg(description = "Merge request number") int number,
109
+ @ToolArg(description = "File path the comment anchors to") String filePath,
110
+ @ToolArg(description = "1-based old-side line number, or -1 for an added line") int oldLine,
111
+ @ToolArg(description = "1-based new-side line number, or -1 for a deleted line") int newLine,
112
+ @ToolArg(description = "Comment body") String body)
113
+ {
114
+ User user = principal.require();
115
+ Repository repo = access.requireReadable(user, owner, name);
116
+ MergeRequest mr = requireMergeRequest(repo, number);
117
+ MergeRequestComment comment = comments.add(user, mr, filePath, oldLine, newLine, body);
118
+ return ApiModels.CommentView.of(comment);
119
+ }
120
+
121
+ private MergeRequest requireMergeRequest(Repository repo, int number)
122
+ {
123
+ return mergeRequests.find(repo, number)
124
+ .orElseThrow(() -> new ToolCallException("Merge request !" + number + " not found"));
125
+ }
126
+}
ADD
src/main/java/de/workaround/mcp/RepositoryTools.java
+69 -0
@@ -0,0 +1,69 @@
1
+package de.workaround.mcp;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.api.ApiModels;
6
+import de.workaround.git.GitRepositoryService;
7
+import de.workaround.model.Repository;
8
+import de.workaround.model.User;
9
+import io.quarkiverse.mcp.server.Tool;
10
+import io.quarkiverse.mcp.server.ToolArg;
11
+import jakarta.inject.Inject;
12
+import jakarta.inject.Singleton;
13
+import jakarta.transaction.Transactional;
14
+
15
+/**
16
+ * MCP tools mirroring the repository REST endpoints, so an AI client can manage repositories the same way.
17
+ * Reads follow repository visibility; mutations require a personal access token and repository ownership.
18
+ */
19
+@Singleton
20
+public class RepositoryTools
21
+{
22
+ @Inject
23
+ GitRepositoryService service;
24
+
25
+ @Inject
26
+ McpRepoAccess access;
27
+
28
+ @Inject
29
+ McpPrincipal principal;
30
+
31
+ @Tool(description = "List repositories visible to the caller: all public repositories, plus your own private ones when a valid token is supplied.")
32
+ public List<ApiModels.RepositoryView> listRepositories()
33
+ {
34
+ return service.listVisibleTo(principal.orNull()).stream().map(ApiModels.RepositoryView::of).toList();
35
+ }
36
+
37
+ @Tool(description = "Get a single repository by owner username and repository name.")
38
+ public ApiModels.RepositoryView getRepository(@ToolArg(description = "Owner username") String owner,
39
+ @ToolArg(description = "Repository name") String name)
40
+ {
41
+ return ApiModels.RepositoryView.of(access.requireReadable(principal.orNull(), owner, name));
42
+ }
43
+
44
+ @Tool(description = "Create a new repository owned by the authenticated user. Requires a personal access token.")
45
+ @Transactional
46
+ public ApiModels.RepositoryView createRepository(
47
+ @ToolArg(description = "Repository name (letters, digits, '.', '_', '-')") String name,
48
+ @ToolArg(description = "Visibility: PUBLIC or PRIVATE", defaultValue = "PUBLIC") String visibility,
49
+ @ToolArg(description = "Optional description", defaultValue = "") String description)
50
+ {
51
+ User user = principal.require();
52
+ Repository.Visibility parsed = "PRIVATE".equalsIgnoreCase(visibility)
53
+ ? Repository.Visibility.PRIVATE : Repository.Visibility.PUBLIC;
54
+ Repository repo = service.create(user, name, parsed,
55
+ description == null || description.isBlank() ? null : description);
56
+ return ApiModels.RepositoryView.of(repo);
57
+ }
58
+
59
+ @Tool(description = "Delete a repository you own. Requires a personal access token. This is irreversible.")
60
+ @Transactional
61
+ public String deleteRepository(@ToolArg(description = "Owner username") String owner,
62
+ @ToolArg(description = "Repository name") String name)
63
+ {
64
+ User user = principal.require();
65
+ Repository repo = access.requireReadable(user, owner, name);
66
+ service.delete(user, repo);
67
+ return "Deleted repository " + owner + "/" + name;
68
+ }
69
+}
ADD
src/main/java/de/workaround/mcp/UserTools.java
+25 -0
@@ -0,0 +1,25 @@
1
+package de.workaround.mcp;
2
+
3
+import de.workaround.api.ApiModels;
4
+import de.workaround.model.User;
5
+import io.quarkiverse.mcp.server.Tool;
6
+import jakarta.inject.Inject;
7
+import jakarta.inject.Singleton;
8
+
9
+/**
10
+ * MCP tool exposing the identity a call authenticated as, so a client can verify its personal access token
11
+ * and discover its own handle before performing owner-scoped operations.
12
+ */
13
+@Singleton
14
+public class UserTools
15
+{
16
+ @Inject
17
+ McpPrincipal principal;
18
+
19
+ @Tool(description = "Return the user the supplied personal access token belongs to. Requires a valid token.")
20
+ public ApiModels.UserView currentUser()
21
+ {
22
+ User user = principal.require();
23
+ return new ApiModels.UserView(user.username, user.displayName);
24
+ }
25
+}
ADD
src/test/java/de/workaround/mcp/McpToolsTest.java
+142 -0
@@ -0,0 +1,142 @@
1
+package de.workaround.mcp;
2
+
3
+import java.util.Map;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.http.AccessTokenService;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import io.quarkiverse.mcp.server.test.McpAssured;
13
+import io.quarkiverse.mcp.server.test.McpAssured.McpStreamableTestClient;
14
+import io.quarkus.test.junit.QuarkusTest;
15
+import io.vertx.core.MultiMap;
16
+import jakarta.inject.Inject;
17
+import jakarta.transaction.Transactional;
18
+
19
+import static org.junit.jupiter.api.Assertions.assertFalse;
20
+import static org.junit.jupiter.api.Assertions.assertNotNull;
21
+import static org.junit.jupiter.api.Assertions.assertTrue;
22
+
23
+@QuarkusTest
24
+class McpToolsTest
25
+{
26
+ @Inject
27
+ GitRepositoryService service;
28
+
29
+ @Inject
30
+ AccessTokenService tokenService;
31
+
32
+ @Inject
33
+ User.Repo userRepo;
34
+
35
+ @Test
36
+ void getRepositoryToolReturnsThePublicRepositoryAnonymously()
37
+ {
38
+ User owner = persistUser("mcp-get-" + shortId());
39
+ service.create(owner, "mcpgotten", Repository.Visibility.PUBLIC, null);
40
+
41
+ McpStreamableTestClient client = McpAssured.newStreamableClient()
42
+ .setMcpPath("/mcp").build().connect();
43
+
44
+ client.when()
45
+ .toolsCall("getRepository", Map.of("owner", owner.username, "name", "mcpgotten"), response -> {
46
+ assertFalse(response.isError());
47
+ assertTrue(response.firstContent().asText().text().contains("mcpgotten"));
48
+ })
49
+ .thenAssertResults();
50
+ }
51
+
52
+ @Test
53
+ void listRepositoriesToolSucceedsAnonymously()
54
+ {
55
+ User owner = persistUser("mcp-list-" + shortId());
56
+ service.create(owner, "mcplisted", Repository.Visibility.PUBLIC, null);
57
+
58
+ McpStreamableTestClient client = McpAssured.newStreamableClient()
59
+ .setMcpPath("/mcp").build().connect();
60
+
61
+ // the list tool serializes one content item per repository; assert it succeeds and returns content
62
+ // rather than relying on ordering (other tests create public repos that sort ahead of this one)
63
+ client.when()
64
+ .toolsCall("listRepositories", response -> {
65
+ assertFalse(response.isError());
66
+ assertNotNull(response.firstContent(), "at least the just-created public repo must be listed");
67
+ })
68
+ .thenAssertResults();
69
+ }
70
+
71
+ @Test
72
+ void createRepositoryToolWithoutTokenFails()
73
+ {
74
+ McpStreamableTestClient client = McpAssured.newStreamableClient()
75
+ .setMcpPath("/mcp").build().connect();
76
+
77
+ client.when()
78
+ .toolsCall("createRepository", Map.of("name", "shouldnotexist"), response ->
79
+ assertTrue(response.isError(), "creating without a token must fail"))
80
+ .thenAssertResults();
81
+ }
82
+
83
+ @Test
84
+ void createRepositoryToolWithTokenCreatesTheRepository()
85
+ {
86
+ User owner = persistUser("mcp-create-" + shortId());
87
+ String token = tokenService.create(owner, "mcp").plaintext();
88
+
89
+ McpStreamableTestClient client = McpAssured.newStreamableClient()
90
+ .setMcpPath("/mcp")
91
+ .setAdditionalHeaders(json -> MultiMap.caseInsensitiveMultiMap().add("Authorization", "Bearer " + token))
92
+ .build().connect();
93
+
94
+ client.when()
95
+ .toolsCall("createRepository", Map.of("name", "mcpmade", "visibility", "PUBLIC"), response -> {
96
+ assertFalse(response.isError());
97
+ assertTrue(response.firstContent().asText().text().contains("mcpmade"));
98
+ })
99
+ .thenAssertResults();
100
+
101
+ assertTrue(service.find(owner.username, "mcpmade").isPresent(), "repository must be persisted");
102
+ }
103
+
104
+ @Test
105
+ void currentUserToolReturnsTheTokenOwner()
106
+ {
107
+ User owner = persistUser("mcp-me-" + shortId());
108
+ String token = tokenService.create(owner, "mcp").plaintext();
109
+
110
+ McpStreamableTestClient client = McpAssured.newStreamableClient()
111
+ .setMcpPath("/mcp")
112
+ .setAdditionalHeaders(json -> MultiMap.caseInsensitiveMultiMap().add("Authorization", "Bearer " + token))
113
+ .build().connect();
114
+
115
+ client.when()
116
+ .toolsCall("currentUser", response -> {
117
+ assertFalse(response.isError());
118
+ assertTrue(response.firstContent().asText().text().contains(owner.username));
119
+ })
120
+ .thenAssertResults();
121
+ }
122
+
123
+ private static String shortId()
124
+ {
125
+ return UUID.randomUUID().toString().substring(0, 8);
126
+ }
127
+
128
+ @Transactional
129
+ User persistUser(String name)
130
+ {
131
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
132
+ if (existing != null)
133
+ {
134
+ return existing;
135
+ }
136
+ User user = new User();
137
+ user.oidcSub = name;
138
+ user.username = name;
139
+ user.persist();
140
+ return user;
141
+ }
142
+}