Build a Smarter AI Prompt Server with Quarkus and the Model Context Protocol
Learn how to connect GitHub’s awesome-copilot-prompts to Claude and other AI clients using a production-ready Quarkus MCP Server with persistent caching.
The Model Context Protocol (MCP) is quietly reshaping how AI systems talk to the outside world.
Instead of every IDE or chatbot inventing its own integration format, MCP defines a single way for large-language-model clients to discover tools, fetch data, and use structured prompts from any server.
In this hands-on guide, you’ll build a production-ready MCP Server with Quarkus.
The server will pull curated prompt files from GitHub’s awesome-copilot-prompts repository and expose them to AI clients such as Claude Desktop, IBM Bob, or Cursor through the new Streamable HTTP transport.
By the end, you’ll have a fast, self-refreshing Quarkus service that acts as an intelligent prompt gateway—reusable, debuggable, and cloud-native.
Why MCP + Quarkus?
Enterprises already rely on Quarkus for APIs and microservices. With the Quarkus MCP Server extension, those same skills extend directly into AI integrations.
What you gain
A consistent, versioned contract between your AI assistants and backend data
Native support for both SSE and Streamable HTTP transports
Type-safe annotations for resources, tools, and prompts
Automatic Dev UI integration for testing
This tutorial stays close to real enterprise needs: reproducible builds, observable behavior, and no proprietary magic.
Project Setup
Create the project
quarkus create app \
com.example:awesome-prompts-mcp \
--extension='rest-jackson,quarkus-github-api,quarkus-mcp-server-sse,scheduler' \
--java=21
cd awesome-prompts-mcpThe quarkus-mcp-server-sse extension includes both SSE and Streamable HTTP, so you’re covered for all MCP transports.
We’re also using the quarkus-github-api extension and the scheduler. Check out the full source code on my Github repository.
Configure the application
src/main/resources/application.properties
# MCP server info
quarkus.mcp.server.info.name=Awesome Copilot Prompts
quarkus.mcp.server.info.version=1.0.0
quarkus.mcp.server.root-path=/mcp
quarkus.mcp.server.log.traffic=true
quarkus.mcp.server.transport.stream.enabled=true
# Repository & caching
mcp.repository.owner=github
mcp.repository.name=awesome-copilot
mcp.repository.branch=mainThis configuration:
Identifies the MCP server name and version.
Enables Streamable HTTP as the transport.
Refreshes cached prompts hourly.
You need to make sure to have your GitHub classic API token with public_repo scope ready and export as environment variable before starting your application later on:
export GITHUB_OAUTH=${GITHUB_TOKEN}Read more about creating personal access token.
Prepare the project layout
mkdir -p src/main/java/com/example/{model,github,service,mcp}A simple four-package structure keeps the logic modular:
model– domain recordsgithub– API integrationservice– parsing and cachingmcp– exposed protocol handlers
Domain Model
Prompt record
src/main/java/com/example/model/Prompt.java
package com.example.model;
import java.util.List;
public record Prompt(
String id,
String title,
String description,
String content,
PromptCategory category,
List<String> tags,
String filePath,
String exampleUsage) {
public enum PromptCategory {
CODE_REVIEW, TESTING, DOCUMENTATION, REFACTORING,
DEBUGGING, ARCHITECTURE, SECURITY, PERFORMANCE, GENERAL
}
public String getResourceUri() {
return "prompt://" + id;
}
}Key ideas
recordkeeps it immutable and concise.The inner
enumdefines logical prompt groups.getResourceUri()generates MCP-style URIs likeprompt://code-review-1.
Fetching Prompts from GitHub
GitHub client
src/main/java/com/example/github/AwesomePromptsClient.java
package com.example.github;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.kohsuke.github.GHContent;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GitHub;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class AwesomePromptsClient {
private static final Logger LOG = Logger.getLogger(AwesomePromptsClient.class);
private static final List<String> TARGET_DIRECTORIES = List.of(”prompts”, “instructions”);
@ConfigProperty(name = “mcp.repository.owner”)
String owner;
@ConfigProperty(name = “mcp.repository.name”)
String repo;
@ConfigProperty(name = “mcp.repository.branch”, defaultValue = “main”)
String branch;
public List<GHContent> fetchPromptFiles() throws IOException {
LOG.info(”Fetching prompt files from repository: “ + owner + “/” + repo);
GitHub gh = getGitHubClient();
GHRepository repository = gh.getRepository(owner + “/” + repo);
List<GHContent> allMarkdownFiles = new ArrayList<>();
for (String targetDir : TARGET_DIRECTORIES) {
try {
LOG.info(”Processing directory: /” + targetDir);
List<GHContent> contents = List.copyOf(repository.getDirectoryContent(targetDir, branch));
List<GHContent> markdownFiles = listMarkdownFiles(contents);
allMarkdownFiles.addAll(markdownFiles);
LOG.info(”Found “ + markdownFiles.size() + “ markdown files in /” + targetDir);
} catch (IOException e) {
LOG.warn(”Directory /” + targetDir + “ not found or inaccessible: “ + e.getMessage());
}
}
LOG.info(”Total markdown files found: “ + allMarkdownFiles.size());
return allMarkdownFiles;
}
public String fetchFileContent(String path) throws IOException {
LOG.info(”Fetching file content for: “ + path);
GitHub gh = getGitHubClient();
try (InputStream is = gh.getRepository(owner + “/” + repo)
.getFileContent(path, branch)
.read()) {
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
LOG.info(”Successfully fetched “ + content.length() + “ characters from “ + path);
return content;
}
}
private GitHub getGitHubClient() throws IOException {
return GitHub.connect();
}
private List<GHContent> listMarkdownFiles(List<GHContent> contents) throws IOException {
return contents.stream()
.<GHContent>flatMap(c -> {
try {
if (c.isDirectory()) {
// Recursively process subdirectories
return listMarkdownFiles(c.listDirectoryContent().toList()).stream();
}
if (c.getName().endsWith(”.md”)) {
return Stream.of(c);
}
} catch (IOException ignored) {
}
return Stream.empty();
})
.toList();
}
}Highlights
Uses the GitHub client
Traverses subdirectories recursively.
Filters only Markdown files to treat each as a prompt.
Parsing Markdown into Prompts
Prompt parser
src/main/java/com/example/service/PromptParser.java
package com.example.service;
import com.example.model.Prompt;
import jakarta.enterprise.context.ApplicationScoped;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ApplicationScoped
public class PromptParser {
// Markdown + YAML patterns
private static final Pattern TITLE = Pattern.compile(”^#\\s+(.+)$”, Pattern.MULTILINE);
private static final Pattern CATEGORY = Pattern.compile(”(?i)category[:\\s]+([\\w\\s-]+)”);
private static final Pattern TAGS = Pattern.compile(”(?i)tags[:\\s]+(.+)$”, Pattern.MULTILINE);
private static final Pattern FRONT_MATTER = Pattern.compile(”^---\\s*\\R(?s)(.*?)\\R---\\s*”, Pattern.MULTILINE);
private static final Pattern FM_LINE = Pattern.compile(”^([A-Za-z0-9_-]+)\\s*:\\s*(.+?)\\s*$”, Pattern.MULTILINE);
public Prompt parseMarkdownFile(String content, String path) {
Map<String, String> fm = parseFrontMatter(content);
String id = fm.getOrDefault(”id”, generateIdFromPath(path));
String title = extract(TITLE, content, fm.getOrDefault(”title”, “Untitled Prompt”));
String description = fm.getOrDefault(”description”, extractDescription(content));
Prompt.PromptCategory category = parseCategory(
fm.getOrDefault(”category”, extract(CATEGORY, content, null)),
path,
content);
List<String> tags = parseTags(fm);
if (tags.isEmpty())
tags = extractTagsFromBody(content);
return new Prompt(
id,
title,
description,
content,
category,
tags,
path,
“”);
}
/* ----------------- YAML front-matter parsing ----------------- */
private Map<String, String> parseFrontMatter(String content) {
Matcher m = FRONT_MATTER.matcher(content);
if (!m.find())
return Collections.emptyMap();
String block = m.group(1);
Map<String, String> map = new LinkedHashMap<>();
Matcher line = FM_LINE.matcher(block);
while (line.find()) {
String key = line.group(1).trim().toLowerCase(Locale.ROOT);
String val = stripQuotes(line.group(2).trim());
map.put(key, val);
}
return map;
}
private String stripQuotes(String s) {
if ((s.startsWith(”’”) && s.endsWith(”’”)) || (s.startsWith(”\”“) && s.endsWith(”\”“))) {
return s.substring(1, s.length() - 1);
}
return s;
}
/* ----------------- Tag handling ----------------- */
private List<String> parseTags(Map<String, String> fm) {
String raw = fm.getOrDefault(”tags”, fm.get(”mode”)); // fall back to “mode”
if (raw == null || raw.isBlank())
return List.of();
// Accept comma, semicolon, or space separation
String normalized = raw.replaceAll(”[;]”, “,”);
String[] parts = normalized.contains(”,”) ? normalized.split(”,”) : normalized.split(”\\s+”);
Set<String> result = new LinkedHashSet<>();
for (String part : parts) {
String t = part.trim().toLowerCase(Locale.ROOT);
if (!t.isEmpty())
result.add(t);
}
return new ArrayList<>(result);
}
private List<String> extractTagsFromBody(String content) {
String raw = extract(TAGS, content, null);
if (raw == null || raw.isBlank())
return List.of();
String[] parts = raw.replaceAll(”[;]”, “,”).split(”[,\\s]+”);
List<String> tags = new ArrayList<>();
for (String p : parts) {
String t = p.trim().toLowerCase(Locale.ROOT);
if (!t.isEmpty())
tags.add(t);
}
return tags;
}
/* ----------------- Category inference ----------------- */
private Prompt.PromptCategory parseCategory(String rawCategory, String path, String content) {
if (rawCategory != null && !rawCategory.isBlank()) {
try {
return Prompt.PromptCategory.valueOf(
rawCategory.toUpperCase(Locale.ROOT).replaceAll(”[\\s-]”, “_”));
} catch (IllegalArgumentException ignored) {
/* fall through */ }
}
return inferCategory(path, content);
}
private Prompt.PromptCategory inferCategory(String path, String content) {
String all = (path + “ “ + content).toLowerCase(Locale.ROOT);
if (all.contains(”architecture”) || all.contains(”arch”))
return Prompt.PromptCategory.ARCHITECTURE;
if (all.contains(”review”))
return Prompt.PromptCategory.CODE_REVIEW;
if (all.contains(”test”))
return Prompt.PromptCategory.TESTING;
if (all.contains(”doc”))
return Prompt.PromptCategory.DOCUMENTATION;
if (all.contains(”refactor”))
return Prompt.PromptCategory.REFACTORING;
if (all.contains(”debug”))
return Prompt.PromptCategory.DEBUGGING;
if (all.contains(”security”))
return Prompt.PromptCategory.SECURITY;
if (all.contains(”performance”) || all.contains(”perf”))
return Prompt.PromptCategory.PERFORMANCE;
return Prompt.PromptCategory.GENERAL;
}
/* ----------------- Description and title helpers ----------------- */
private String extract(Pattern p, String text, String def) {
if (text == null)
return def;
Matcher m = p.matcher(text);
return m.find() ? m.group(1).trim() : def;
}
private String extractDescription(String text) {
String[] lines = text.split(”\n”);
StringBuilder sb = new StringBuilder();
boolean afterTitle = false;
for (String line : lines) {
if (line.startsWith(”#”)) {
afterTitle = true;
continue;
}
if (afterTitle && !line.isBlank())
sb.append(line.trim()).append(’ ‘);
if (sb.length() > 200)
break;
}
return sb.toString().trim();
}
/* ----------------- ID generation ----------------- */
private String generateIdFromPath(String path) {
String filename = Path.of(path).getFileName().toString();
filename = filename.replaceFirst(”(?i)\\.prompt\\.md$”, “”).replaceFirst(”(?i)\\.md$”, “”);
String base = filename.replaceAll(”[^A-Za-z0-9]+”, “-”)
.replaceAll(”^-+|-+$”, “”)
.toLowerCase(Locale.ROOT);
// Stable 6-char suffix from full relative path
String shortHash = Integer.toHexString(path.toLowerCase(Locale.ROOT).hashCode());
shortHash = shortHash.length() > 6 ? shortHash.substring(0, 6) : shortHash;
return base + “-” + shortHash;
}
}What this does
Parses lightweight YAML front-matter (
--- ... ---) without adding dependencies.Extracts metadata such as
id,description,category,tags, and falls back tomodeiftagsare missing.Generates clean, stable IDs from filenames (e.g.
breakdown-epic-arch).Infers missing categories like
ARCHITECTUREfrom filename or content.Normalizes and deduplicates tags for consistent downstream use.
Provides resilient defaults so even minimal prompt files classify correctly.
Repository and Caching
src/main/java/com/example/service/PromptRepository.java
package com.example.service;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jboss.logging.Logger;
import org.kohsuke.github.GHContent;
import com.example.github.AwesomePromptsClient;
import com.example.model.Prompt;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.runtime.Startup;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class PromptRepository {
private static final Logger LOG = Logger.getLogger(PromptRepository.class);
private static final Path CACHE_DIR = Paths.get(”target”, “cache”);
@Inject
AwesomePromptsClient github;
@Inject
PromptParser parser;
@Inject
ObjectMapper mapper;
private final Map<String, Prompt> cache = new ConcurrentHashMap<>();
private volatile boolean refreshing = false;
@Startup
void init() {
try {
Files.createDirectories(CACHE_DIR);
loadFromCache();
if (cache.isEmpty()) {
LOG.info(”No cached prompts found — fetching from GitHub...”);
refreshPrompts();
} else {
LOG.infof(”Loaded %d prompts from local cache”, cache.size());
}
} catch (Exception e) {
LOG.error(”Failed to initialize prompt cache”, e);
refreshPrompts();
}
}
@Scheduled(every = “60m”)
public void refreshPrompts() {
if (refreshing) {
LOG.info(”Refresh already in progress, skipping...”);
return;
}
refreshing = true;
try {
LOG.info(”Refreshing prompts...”);
List<GHContent> files = github.fetchPromptFiles();
int newCount = 0;
int skipped = 0;
for (GHContent file : files) {
String id = generateIdFromPath(file.getPath());
Path cachedFile = CACHE_DIR.resolve(id + “.json”);
if (Files.exists(cachedFile)) {
skipped++;
continue;
}
try {
String content = github.fetchFileContent(file.getPath());
Prompt prompt = parser.parseMarkdownFile(content, file.getPath());
cache.put(prompt.id(), prompt);
savePromptToCache(prompt);
newCount++;
} catch (Exception ex) {
LOG.warnf(”Failed to parse %s: %s”, file.getPath(), ex.getMessage());
}
}
LOG.infof(”Prompt refresh complete. Added %d new prompts, skipped %d (already cached)”, newCount, skipped);
LOG.infof(”Total prompts in memory: %d”, cache.size());
} catch (Exception e) {
LOG.error(”Failed to refresh prompts”, e);
} finally {
refreshing = false;
}
}
public List<Prompt> all() {
return new ArrayList<>(cache.values());
}
public Prompt byId(String id) {
Prompt prompt = cache.get(id);
if (prompt != null)
return prompt;
// Try lazy load from file cache
Path cachedFile = CACHE_DIR.resolve(id + “.json”);
if (Files.exists(cachedFile)) {
try {
prompt = mapper.readValue(cachedFile.toFile(), Prompt.class);
cache.put(prompt.id(), prompt);
return prompt;
} catch (IOException e) {
LOG.warnf(”Failed to read cached file %s: %s”, id, e.getMessage());
}
}
return null;
}
public List<Prompt> search(String q, Prompt.PromptCategory c, List<String> tags) {
return cache.values().stream()
.filter(p -> q == null || p.title().toLowerCase().contains(q.toLowerCase()))
.filter(p -> c == null || p.category() == c)
.filter(p -> tags == null || tags.isEmpty() || p.tags().stream().anyMatch(tags::contains))
.toList();
}
/* ---------------- Helper methods ---------------- */
private void loadFromCache() {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(CACHE_DIR, “*.json”)) {
for (Path file : stream) {
try {
Prompt prompt = mapper.readValue(file.toFile(), Prompt.class);
cache.put(prompt.id(), prompt);
} catch (Exception e) {
LOG.warnf(”Skipping invalid cache file %s: %s”, file.getFileName(), e.getMessage());
}
}
} catch (NoSuchFileException e) {
LOG.info(”No existing cache directory”);
} catch (IOException e) {
LOG.warn(”Failed to read local prompt cache”, e);
}
}
private void savePromptToCache(Prompt prompt) {
Path path = CACHE_DIR.resolve(prompt.id() + “.json”);
try {
mapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), prompt);
} catch (IOException e) {
LOG.warnf(”Failed to write cache file %s: %s”, path, e.getMessage());
}
}
private String generateIdFromPath(String path) {
// Use the same ID generation logic as PromptParser
return path.replaceAll(”[^a-zA-Z0-9]”, “-”).toLowerCase();
}
}
Key points
Caches prompts in-memory for instant lookup.
Caches json files per prompt in target/cache
Refreshes automatically via
@Scheduled.Keeps the design thread-safe with
ConcurrentHashMap.
Exposing the MCP Server
With data in place, it’s time to expose it through MCP endpoints.
Each logical group, resources, tools, and prompts, is declared inside a class annotated with @Singleton.
Resources
src/main/java/com/example/mcp/PromptResources.java
package com.example.mcp;
import java.util.List;
import com.example.model.Prompt;
import com.example.service.PromptRepository;
import io.quarkiverse.mcp.server.RequestUri;
import io.quarkiverse.mcp.server.Resource;
import io.quarkiverse.mcp.server.ResourceTemplate;
import io.quarkiverse.mcp.server.TextResourceContents;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class PromptResources {
@Inject
PromptRepository repo;
@Resource(uri = “prompt://list”, description = “List all available prompts”)
public TextResourceContents listAll(RequestUri uri) {
List<Prompt> prompts = repo.all();
StringBuilder out = new StringBuilder(”# Available Prompts\n\n”);
prompts.forEach(
p -> out.append(String.format(”- **%s** (%s): %s%n”, p.title(), p.category(), p.description())));
return TextResourceContents.create(uri.value(), out.toString());
}
@ResourceTemplate(uriTemplate = “prompt://{id}”, description = “Fetch a specific prompt”)
public TextResourceContents get(String id, RequestUri uri) {
Prompt p = repo.byId(id);
return p == null
? TextResourceContents.create(uri.value(), “Prompt not found: “ + id)
: TextResourceContents.create(uri.value(), p.content());
}
}Concepts
Each
@Resource and @ResourceTemplatecorresponds to a virtual URI discoverable by the client.TextResourceContentswraps plain text for MCP transport.Quarkus auto-registers these resources on startup.
Tools
src/main/java/com/example/mcp/PromptTools.java
package com.example.mcp;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.example.model.Prompt;
import com.example.service.PromptRepository;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class PromptTools {
@Inject
PromptRepository repo;
@Tool(description = “Search prompts by query and category”)
public String search(
@ToolArg(description = “Search text”) String query,
@ToolArg(description = “Category”) String category) {
Prompt.PromptCategory cat = null;
if (category != null && !category.isBlank()) {
try {
cat = Prompt.PromptCategory.valueOf(category.toUpperCase());
} catch (IllegalArgumentException e) {
return “Invalid category: “ + category;
}
}
List<Prompt> res = repo.search(query, cat, List.of());
if (res.isEmpty())
return “No prompts found.”;
return res.stream()
.map(p -> String.format(”• %s (%s)%n”, p.title(), p.category()))
.collect(Collectors.joining());
}
@Tool(description = “List categories with prompt counts”)
public String categories() {
Map<Prompt.PromptCategory, Long> counts = repo.all().stream()
.collect(Collectors.groupingBy(Prompt::category, Collectors.counting()));
return counts.entrySet().stream()
.map(e -> e.getKey() + “: “ + e.getValue())
.collect(Collectors.joining(”\n”));
}
}Highlights
Tools act like callable functions for the AI client.
Parameters are annotated with
@ToolArg.Return values can be plain strings. MCP handles encoding.
Prompts
src/main/java/com/example/mcp/Prompts.java
package com.example.mcp;
import com.example.service.PromptRepository;
import io.quarkiverse.mcp.server.Prompt;
import io.quarkiverse.mcp.server.PromptArg;
import io.quarkiverse.mcp.server.PromptMessage;
import io.quarkiverse.mcp.server.TextContent;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class Prompts {
@Inject
PromptRepository repo;
@Prompt(description = “Apply a stored prompt with optional context”)
public PromptMessage apply(
@PromptArg(description = “Prompt ID”) String promptId,
@PromptArg(description = “Context text”) String context) {
com.example.model.Prompt p = repo.byId(promptId);
if (p == null)
return PromptMessage.withUserRole(new TextContent(”Prompt not found: “ + promptId));
String full = p.content();
if (!context.isBlank())
full += “\n\n---\n**Context:**\n```\n” + context + “\n```”;
return PromptMessage.withUserRole(new TextContent(full));
}
}Why it matters
Prompts describe pre-formatted messages that an LLM can reuse.
PromptMessage.withUserRole()declares who “speaks” in the conversation.This structure enables multi-turn AI workflows directly from your Quarkus app.
Local Verification
Dev-mode endpoint
package com.example;
import com.example.service.PromptRepository;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
@Path(”/dev”)
@Produces(MediaType.TEXT_PLAIN)
public class DevEndpoint {
@Inject PromptRepository repo;
@GET @Path(”/count”)
public String count() { return “Prompts loaded: “ + repo.all().size(); }
@POST @Path(”/refresh”)
public String refresh() { repo.refreshPrompts(); return “Refreshed”; }
}Run the app:
quarkus devIt will take a while but when the initial parsing of prompts is done, Quarkus will log something similar to below:
Prompt refresh complete. Added 213 new prompts, skipped 0 (already cached)
Total prompts in memory: 213You can also take a look at the target/cache directory to find the individual prompts as JSON files there.
Open http://localhost:8080/q/dev → the MCP Server card lists all resources, tools, and prompts.
You can trigger them directly from the browser.
Integrating with Claude Desktop
macOS configuration
~/Library/Application Support/Claude/claude_desktop_config.json
{
“mcpServers”: {
“awesome-prompts”: {
“command”: “curl”,
“args”: [
“-N”,
“-H”, “Accept: application/json”,
“http://localhost:8080/mcp”
]
}
}
}
The 🔌 icon in the corner now lists Awesome Copilot Prompts.
Ask it:
“List available code-review prompts.”
“Search for performance tips.”
Claude will fetch results through.
That was not only fun but also simple and straight forward.




