gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (mcp): Expose features as MCP tools over Streamable HTTP

5236d19847086692c99c5b79d5a5835232a7182e · Miggi · 2026-07-08T16:07:05Z

Changes

10 files changed, +600 -4

MODIFY README.md +22 -0
diff --git a/README.md b/README.md
index 5228669..007fa33 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/pom.xml b/pom.xml
index c93b49e..e7f9838 100644
--- a/pom.xml
+++ b/pom.xml
@@ -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
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index b18eb2f..c05d50c 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/IssueTools.java b/src/main/java/de/workaround/mcp/IssueTools.java
new file mode 100644
index 0000000..2ba3e4d
--- /dev/null
+++ b/src/main/java/de/workaround/mcp/IssueTools.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/McpPrincipal.java b/src/main/java/de/workaround/mcp/McpPrincipal.java
new file mode 100644
index 0000000..6b3315d
--- /dev/null
+++ b/src/main/java/de/workaround/mcp/McpPrincipal.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/McpRepoAccess.java b/src/main/java/de/workaround/mcp/McpRepoAccess.java
new file mode 100644
index 0000000..47fa5d4
--- /dev/null
+++ b/src/main/java/de/workaround/mcp/McpRepoAccess.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/MergeRequestTools.java b/src/main/java/de/workaround/mcp/MergeRequestTools.java
new file mode 100644
index 0000000..e1f35b3
--- /dev/null
+++ b/src/main/java/de/workaround/mcp/MergeRequestTools.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/RepositoryTools.java b/src/main/java/de/workaround/mcp/RepositoryTools.java
new file mode 100644
index 0000000..9664bd6
--- /dev/null
+++ b/src/main/java/de/workaround/mcp/RepositoryTools.java
@@ -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
diff --git a/src/main/java/de/workaround/mcp/UserTools.java b/src/main/java/de/workaround/mcp/UserTools.java
new file mode 100644
index 0000000..ab0967a
--- /dev/null
+++ b/src/main/java/de/workaround/mcp/UserTools.java
@@ -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
diff --git a/src/test/java/de/workaround/mcp/McpToolsTest.java b/src/test/java/de/workaround/mcp/McpToolsTest.java
new file mode 100644
index 0000000..a1fd7a3
--- /dev/null
+++ b/src/test/java/de/workaround/mcp/McpToolsTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog