Agent-Ready APIs Need Boring Discovery, Not AI Magic
A Quarkus implementation of Cloudflare-style readiness checks that makes API discovery, authorization metadata, and tool access explicit before agents start guessing.
The first request from an agent is usually a tiny archaeology project. It knows a host name, maybe one URL, and then it has to guess where the useful machine-readable bits live. Humans can click through docs. Agents start with HTTP, headers, and a small amount of patience.
Cloudflare’s Agent Readiness score turns that problem into a concrete audit. The scanner at isitagentready.com checks discoverability, content accessibility, bot access control, protocol discovery, and commerce signals. Some of those checks are old web plumbing wearing new boots. Some are new enough that the paint is still wet.
For me, “agent-ready” starts with the boring parts that remove guessing. Give the client crawl rules, a sitemap, Link headers, a compact llms.txt, a real OpenAPI pointer, OAuth protected-resource metadata, and an MCP endpoint if you expose tools. None of this makes the API smarter. It just stops wasting requests on detective work.
We will build that around Meridian, a small Quarkus knowledge-base API with public article search, protected write endpoints, Markdown article content, Keycloak-backed OAuth, and MCP tools. The domain is deliberately boring. That is good; the plumbing is the thing we care about here.
This article uses Java 21 and Quarkus 3.34.5. The Quarkiverse MCP HTTP extension is brought in through the Quarkus platform MCP server BOM, so the extension version follows the platform you choose.
The final project lives at https://github.com/myfear/the-main-thread/meridian-agent-ready.
What We Build
Meridian exposes these agent-facing pieces:
robots.txt,sitemap.xml, andllms.txtat the rootRFC 8288 Link headers from the API root
Markdown responses for article content when the client sends
Accept: text/markdownContent usage signals on article responses and crawler rules
OAuth 2.0 Protected Resource Metadata from Quarkus OIDC
an RFC 9727 API Catalog that links to the Quarkus OpenAPI document
a Quarkus MCP server over HTTP/SSE
an MCP Server Card for pre-connection discovery
an Agent Skills discovery index with a small
SKILL.md
The maturity is uneven. robots.txt, sitemaps, Link headers, OpenAPI, OIDC, and RFC 9728 are boring enough to ship. The MCP endpoint is practical today. API Catalog is a real RFC, but client support is still early. llms.txt, Content Signals, Markdown negotiation, MCP Server Cards, and Agent Skills are useful conventions with uneven adoption. Ship them with clear boundaries. New standards are where confident blog posts go to embarrass themselves six months later. Including this article probably. We will see.
What You Need
You need a current Quarkus CLI, Java 21, Maven, and a container runtime if you want Keycloak Dev Services locally.
Java 21
Quarkus CLI
Maven 3.9+
Podman or Docker for Keycloak Dev Services
Basic Jakarta REST and OAuth knowledge
Some amount of your favorite beverage ☕️
Create the project or directly clone the repository.
quarkus create app dev.the-main-thread:meridian \
--package-name=dev.themainthread.meridian \
--extension=quarkus-rest,quarkus-rest-jackson,quarkus-smallrye-openapi,quarkus-smallrye-health,quarkus-oidc,io.quarkiverse.mcp:quarkus-mcp-server-httpUse --package-name because dev.the-main-thread is a fine Maven group id and a broken Java package. quarkus-smallrye-health is optional, but the API catalog below links to /q/health, so we add it here instead of pretending the endpoint appears by magic.
The extensions are simple enough:
quarkus-rest: Jakarta REST endpointsquarkus-rest-jackson: JSON serializationquarkus-smallrye-openapi: generated OpenAPI at/q/openapiquarkus-smallrye-health: health endpoint for catalog status linksquarkus-oidc: bearer-token security and protected-resource metadataio.quarkiverse.mcp:quarkus-mcp-server-http: MCP over Streamable HTTP and SSE
The MCP artifact name matters. Older examples used quarkus-mcp-server-sse. The current Quarkiverse extension page lists quarkus-mcp-server-http; it still exposes the SSE endpoint at /mcp/sse when you use the HTTP transport.
Static Discovery Files
Start with files. It is hard to beat a static file when the job is “be there before any application logic runs.”
Quarkus serves static files from src/main/resources/META-INF/resources/. A file at src/main/resources/META-INF/resources/robots.txt becomes /robots.txt.
robots.txt
robots.txt is still about crawl access. It tells automated clients which paths they may fetch. Cloudflare-style Content Signals can also live there for crawlers that understand them.
Create src/main/resources/META-INF/resources/robots.txt:
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /internal/
User-agent: GPTBot
Allow: /
Disallow: /admin/
Disallow: /internal/
User-agent: ClaudeBot
Allow: /
Disallow: /admin/
Disallow: /internal/
Content-Signal: search=yes, ai-input=yes, ai-train=no
Sitemap: https://api.meridian.dev/sitemap.xmlThe crawler rules and the usage signal do different jobs. Disallow blocks fetching a path. Content-Signal states allowed use for content the crawler is allowed to fetch. It is trust-based, so it works only with clients that respect the signal. Annoying, but still better than silence.
sitemap.xml
A REST API needs a sitemap with stable public entry points, not every protected write URL you ever added.
Create src/main/resources/META-INF/resources/sitemap.xml:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://api.meridian.dev/api/v1/articles</loc>
<changefreq>hourly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://api.meridian.dev/q/openapi?format=json</loc>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://api.meridian.dev/.well-known/api-catalog</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>If articles are public and individually addressable, generate this from your database instead. Keep the URL stable and change the implementation behind it.
llms.txt
llms.txt is a convention, not an IETF standard. Keep it short. It gives a model a small map of the service and links to the files worth reading.
Create src/main/resources/META-INF/resources/llms.txt:
# Meridian Knowledge API
> Meridian is a Quarkus API for searching and reading structured knowledge
> articles. Public clients can search and retrieve article content. Write
> operations require OAuth bearer tokens from the Meridian realm.
Meridian exposes JSON for API operations and Markdown for article content when
clients send `Accept: text/markdown`.
## API
- [OpenAPI JSON](https://api.meridian.dev/q/openapi?format=json): Machine-readable REST API contract
- [API catalog](https://api.meridian.dev/.well-known/api-catalog): RFC 9727 Linkset catalog
- [OAuth protected resource metadata](https://api.meridian.dev/.well-known/oauth-protected-resource): Authorization server and scope discovery
- [MCP Server Card](https://api.meridian.dev/.well-known/mcp/server-card.json): Pre-connection MCP discovery
- [Agent Skills index](https://api.meridian.dev/.well-known/agent-skills/index.json): Skills published for agent clients
## Content
- [Article search](https://api.meridian.dev/api/v1/articles): Public article search endpoint
- [MCP endpoint](https://api.meridian.dev/mcp): MCP Streamable HTTP endpoint
- [MCP SSE endpoint](https://api.meridian.dev/mcp/sse): Compatibility endpoint for older MCP clients
## Optional
- [Human documentation](https://docs.meridian.dev): Developer guide and examplesThat is enough. Once this becomes a second documentation site, you have invented documentation drift with a nicer filename.
Link Headers at the Front Door
Cloudflare’s scanner also looks for RFC 8288 Link headers because an agent should not have to parse HTML before it can find the useful machine-readable documents. For an API, I like making / a tiny discovery response instead of a blank 404. It gives humans a pulse check and gives agents headers they can follow.
Create src/main/java/dev/themainthread/meridian/resource/DiscoveryResource.java:
package dev.themainthread.meridian.resource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
@Path("/")
public class DiscoveryResource {
@ConfigProperty(name = "meridian.api.base-url")
String apiBaseUrl;
@GET
@Produces(MediaType.APPLICATION_JSON)
public Response index() {
Map<String, String> body = Map.of(
"service", "Meridian Knowledge API",
"articles", apiBaseUrl + "/api/v1/articles",
"openapi", apiBaseUrl + "/q/openapi?format=json",
"apiCatalog", apiBaseUrl + "/.well-known/api-catalog",
"mcpServerCard", apiBaseUrl + "/.well-known/mcp/server-card.json",
"agentSkills", apiBaseUrl + "/.well-known/agent-skills/index.json");
return withDiscoveryLinks(Response.ok(body), apiBaseUrl).build();
}
static Response.ResponseBuilder withDiscoveryLinks(
Response.ResponseBuilder response, String apiBaseUrl) {
return response
.header("Link",
"<" + apiBaseUrl
+ "/.well-known/api-catalog>; rel=\"api-catalog\"; type=\"application/linkset+json\"")
.header("Link",
"<" + apiBaseUrl + "/q/openapi?format=json>; rel=\"service-desc\"; type=\"application/json\"")
.header("Link",
"<" + apiBaseUrl + "/llms.txt>; rel=\"describedby\"; type=\"text/plain\"")
.header("Link",
"<" + apiBaseUrl
+ "/.well-known/mcp/server-card.json>; rel=\"mcp-server-card\"; type=\"application/json\"")
.header("Link",
"<" + apiBaseUrl
+ "/.well-known/agent-skills/index.json>; rel=\"agent-skills\"; type=\"application/json\"");
}
}
service-desc and describedby are broadly useful. api-catalog, mcp-server-card, and agent-skills are newer relation names, so treat them as friendly hints rather than the only discovery path. The actual files still need stable URLs.
Markdown for Article Content
Cloudflare’s Markdown for Agents feature made one convention visible: clients can ask for Markdown with Accept: text/markdown, and the response can carry Vary: Accept, X-Markdown-Tokens, and Content-Signal. The CDN can do this, but a Quarkus app can do it itself.
I prefer choosing the representation in the resource method. A response filter that rewrites text/html after Jakarta REST has already picked a response type looks elegant, then quietly serves HTML with a Markdown content type. That is a small bug with excellent hiding skills.
Add the HTML-to-Markdown converter:
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-html2md-converter</artifactId>
<version>0.64.8</version>
</dependency>Then expose article content like this. Put list, create, and content negotiation on the same Jakarta REST class with @Path("/api/v1/articles"). Registering two application-scoped resources with that identical class-level path is brittle; merge the methods instead.
package dev.themainthread.meridian.resource;
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
import dev.themainthread.meridian.service.Article;
import dev.themainthread.meridian.service.ArticleListResponse;
import dev.themainthread.meridian.service.ArticleService;
import dev.themainthread.meridian.service.CreateArticleRequest;
import io.quarkus.security.Authenticated;
import jakarta.annotation.security.PermitAll;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
@ApplicationScoped
@Path("/api/v1/articles")
public class ArticlesResource {
private static final MediaType TEXT_MARKDOWN_TYPE =
new MediaType("text", "markdown");
private static final FlexmarkHtmlConverter HTML_TO_MARKDOWN =
FlexmarkHtmlConverter.builder().build();
@Inject
ArticleService articleService;
@GET
@PermitAll
@Produces(MediaType.APPLICATION_JSON)
@Operation(
summary = "Search and list public articles",
description = """
Searches public knowledge articles by title and body text. When the q
parameter is present, results are ordered by search relevance. Without q,
results are ordered by publication date. This endpoint does not require
authentication.
""")
@APIResponse(responseCode = "200", description = "Paginated article list")
public ArticleListResponse listArticles(
@QueryParam("q") String query,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
return articleService.search(query, page, size);
}
@POST
@Authenticated
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response createArticle(CreateArticleRequest request) {
Article created = articleService.create(request.title(), request.content());
return Response.created(
UriBuilder.fromPath("/api/v1/articles").path(created.id()).build())
.entity(created)
.build();
}
@GET
@PermitAll
@Path("/{id}/content")
public Response getArticleContent(@PathParam("id") String id,
@Context HttpHeaders headers) {
Article article = articleService.findById(id)
.orElseThrow(NotFoundException::new);
if (acceptsMarkdown(headers)) {
String markdown = article.markdownContent();
if (markdown == null || markdown.isBlank()) {
markdown = HTML_TO_MARKDOWN.convert(article.htmlContent());
}
return Response.ok(markdown, TEXT_MARKDOWN_TYPE)
.header("Vary", HttpHeaders.ACCEPT)
.header("X-Markdown-Tokens", estimateTokens(markdown))
.header("Content-Signal", "search=yes, ai-input=yes, ai-train=no")
.build();
}
return Response.ok(article.htmlContent(), MediaType.TEXT_HTML_TYPE)
.header("Vary", HttpHeaders.ACCEPT)
.build();
}
private boolean acceptsMarkdown(HttpHeaders headers) {
for (MediaType requested : headers.getAcceptableMediaTypes()) {
if (requested.isWildcardType() || requested.isWildcardSubtype()) {
return false;
}
if (requested.isCompatible(TEXT_MARKDOWN_TYPE)) {
return true;
}
if (requested.isCompatible(MediaType.TEXT_HTML_TYPE)) {
return false;
}
}
return false;
}
private int estimateTokens(String value) {
return Math.max(1, value.length() / 4);
}
}
For production, store Markdown at authoring time and render HTML from it. Converting HTML back to Markdown is fine as a migration bridge. I would not make that the long-term content model, because some structure is already gone by the time you scrape rendered HTML back into text.
The Vary: Accept header is the cache safety bit. Without it, a CDN can cache the HTML response and serve it to a client that asked for Markdown. That failure looks like “the agent is dumb” until you check the headers.
Content Usage Signals
The Markdown endpoint above sets Content-Signal on article content. For the JAX-RS responses, use a small response filter. Static files are served by the HTTP layer before this filter matters, so keep crawler-facing signals directly in robots.txt.
package dev.themainthread.meridian.filter;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.ext.Provider;
import java.io.IOException;
@Provider
public class ContentSignalsFilter implements ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) throws IOException {
// Use getPath(true): RESTEasy Reactive (Quarkus REST) rejects getPath(false)
// with "We do not support non-decoded parameters".
String path = requestContext.getUriInfo().getPath(true);
if (path.startsWith("api/v1/articles") || path.startsWith("/api/v1/articles")) {
responseContext.getHeaders().putSingle(
"Content-Signal",
"search=yes, ai-input=yes, ai-train=no");
return;
}
if (path.startsWith(".well-known")
|| path.startsWith("/.well-known")
|| path.equals("robots.txt")
|| path.equals("/robots.txt")
|| path.equals("llms.txt")
|| path.equals("/llms.txt")) {
responseContext.getHeaders().putSingle(
"Content-Signal",
"search=yes, ai-input=yes, ai-train=yes");
}
}
}
With getPath(true), the path may include a leading slash depending on the runtime. Match both api/v1/... and /api/v1/.... Also avoid getPath(false) on Quarkus REST (RESTEasy Reactive): it throws IllegalArgumentException because non-decoded paths are not supported.
OAuth Discovery with RFC 9728
For protected resources, use RFC 9728 OAuth 2.0 Protected Resource Metadata. This tells a client which authorization server protects the API and which scopes are worth asking for.
You do not need to hand-write the metadata document in Quarkus. quarkus-oidc has protected resource metadata support, and it is disabled by default for a good reason: publishing authorization-server details is a deliberate choice.
Keep the shared OIDC behavior separate from the production host names:
quarkus.oidc.client-id=meridian-api
quarkus.oidc.application-type=service
quarkus.oidc.resource-metadata.enabled=true
quarkus.oidc.resource-metadata.scopes=read,write,admin
Then set production URLs in the production profile:
%prod.meridian.api.base-url=https://api.meridian.dev
%prod.quarkus.oidc.auth-server-url=https://auth.meridian.dev/realms/meridian
%prod.quarkus.oidc.resource-metadata.resource=${meridian.api.base-url}
For local verification, use a dev profile. Leave quarkus.oidc.auth-server-url unset in dev if you want Quarkus Dev Services to start Keycloak for you. Quarkus forces HTTPS resource metadata by default, which is correct for production and annoying for localhost:
%dev.meridian.api.base-url=http://localhost:8080
%dev.quarkus.oidc.resource-metadata.resource=${meridian.api.base-url}
%dev.quarkus.oidc.resource-metadata.force-https-scheme=false
With that enabled, the default protected resource metadata route is:
/.well-known/oauth-protected-resource
A client that calls a protected endpoint without a token should also see a challenge like this:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.meridian.dev/.well-known/oauth-protected-resource"
The parameter name is resource_metadata. Do not use as_uri here. That is not the RFC 9728 contract, and some OAuth-aware clients will simply miss the metadata URL.
Treat jwks_uri carefully. In RFC 9728 it belongs to the protected resource, for cases where the resource signs responses. It is separate from the Keycloak realm certificate endpoint used to verify access tokens. Meridian does not sign resource responses, so we leave it out.
Metadata is still only metadata. It does not enforce scopes. Protect your write methods with io.quarkus.security.Authenticated, @PermissionsAllowed, route permissions, or your normal Keycloak authorization setup. The discovery document helps the client ask for the right token; it does not make a bad token good.
API Catalog with RFC 9727
OpenAPI tells a client what operations exist. The RFC 9727 API Catalog tells it where to find those API descriptions.
Quarkus already exposes OpenAPI at /q/openapi; request JSON with /q/openapi?format=json. The catalog at /.well-known/api-catalog should be a Linkset document, not a custom { "apis": [...] } object.
Create a resource for the catalog:
package dev.themainthread.meridian.resource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HEAD;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
@Path("/.well-known")
public class ApiCatalogResource {
private static final String API_CATALOG_TYPE =
"application/linkset+json; profile=\"https://www.rfc-editor.org/info/rfc9727\"";
@ConfigProperty(name = "meridian.api.base-url")
String apiBaseUrl;
@HEAD
@Path("/api-catalog")
public Response apiCatalogHead() {
return DiscoveryResource.withDiscoveryLinks(Response.noContent(), apiBaseUrl)
.build();
}
@GET
@Path("/api-catalog")
@Produces("application/linkset+json")
public Response apiCatalog() {
Map<String, Object> catalog = Map.of(
"linkset", List.of(
Map.of(
"anchor", apiBaseUrl + "/api/v1",
"service-desc", List.of(
Map.of(
"href", apiBaseUrl + "/q/openapi?format=json",
"type", "application/json"
)
),
"service-doc", List.of(
Map.of(
"href", "https://docs.meridian.dev",
"type", "text/html"
)
),
"status", List.of(
Map.of(
"href", apiBaseUrl + "/q/health",
"type", "application/json"
)
)
)
)
);
return DiscoveryResource.withDiscoveryLinks(Response.ok(catalog), apiBaseUrl)
.type(API_CATALOG_TYPE)
.build();
}
}
Keep the catalog small. It points to the machine-readable descriptions that already exist; it is not the place to rebuild your whole API model.
The listArticles method above also shows why OpenAPI descriptions matter. “Returns a list” is not enough. A planner needs to know when to call the operation, how the results are ordered, and whether it needs a token.
MCP Tool Access
The Quarkiverse MCP HTTP extension exposes the MCP endpoint at /mcp for the current Streamable HTTP transport and /mcp/sse for older SSE clients. The default root path is already /mcp, so only set it when you want to be explicit:
quarkus.mcp.server.http.root-path=/mcp
quarkus.mcp.server.server-info.name=meridian
quarkus.mcp.server.server-info.title=Meridian Knowledge API
quarkus.mcp.server.server-info.version=1.0.0
quarkus.mcp.server.server-info.description=Tools for searching and reading Meridian articles.
Protect the endpoint if the tools expose anything sensitive:
quarkus.http.auth.permission.mcp.paths=/mcp,/mcp/*
quarkus.http.auth.permission.mcp.policy=authenticated
Then implement the tools:
package dev.themainthread.meridian.mcp;
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter;
import dev.themainthread.meridian.service.Article;
import dev.themainthread.meridian.service.ArticleService;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.stream.Collectors;
@ApplicationScoped
public class MeridianMcpTools {
private static final FlexmarkHtmlConverter HTML_TO_MARKDOWN =
FlexmarkHtmlConverter.builder().build();
@Inject
ArticleService articleService;
@Tool(description = "Search public Meridian articles by keyword or phrase.")
public String searchArticles(
@ToolArg(description = "Search keyword or phrase") String query) {
return articleService.search(query, 0, 10).items()
.stream()
.map(article -> article.id() + ": " + article.title())
.collect(Collectors.joining("\n"));
}
@Tool(description = "Read one Meridian article as Markdown.")
public String getArticle(
@ToolArg(description = "Article ID") String id) {
return articleService.findById(id)
.map(MeridianMcpTools::markdownOrConverted)
.orElse("No article found for ID `" + id + "`.");
}
private static String markdownOrConverted(Article article) {
String markdown = article.markdownContent();
if (markdown != null && !markdown.isBlank()) {
return markdown;
}
return HTML_TO_MARKDOWN.convert(article.htmlContent());
}
}
Tool output is not a web page. Return Markdown or compact plain text. HTML in a tool response usually gives the model more tokens and less meaning. That trade is bad and not even interesting.
Cloudflare checks MCP Server Card discovery before a client connects. The MCP protocol still negotiates capabilities during initialize, so the card is a map, not the source of truth. Keep it small and regenerate it when tools change.
Create src/main/java/dev/themainthread/meridian/resource/McpServerCardResource.java:
package dev.themainthread.meridian.resource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
@Path("/.well-known")
public class McpServerCardResource {
@ConfigProperty(name = "meridian.api.base-url")
String apiBaseUrl;
@GET
@Path("/mcp/server-card.json")
@Produces(MediaType.APPLICATION_JSON)
public Response serverCard() {
return Response.ok(card()).build();
}
@GET
@Path("/mcp.json")
@Produces(MediaType.APPLICATION_JSON)
public Response legacyServerCard() {
return Response.ok(card()).build();
}
private Map<String, Object> card() {
return Map.of(
"$schema",
"https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
"version",
"1.0",
"protocolVersion",
"2025-06-18",
"serverInfo",
Map.of(
"name", "meridian",
"title", "Meridian Knowledge API",
"version", "1.0.0"),
"description",
"Search and read public Meridian knowledge articles.",
"transport",
Map.of(
"type", "streamable-http",
"endpoint", apiBaseUrl + "/mcp"),
"authentication",
Map.of(
"required", true,
"schemes", List.of("bearer"),
"resourceMetadata", apiBaseUrl + "/.well-known/oauth-protected-resource"),
"tools",
List.of(
Map.of(
"name", "searchArticles",
"title", "Search articles",
"description", "Search public Meridian articles by keyword or phrase.",
"inputSchema", Map.of(
"type", "object",
"properties", Map.of(
"query", Map.of(
"type", "string",
"description", "Search keyword or phrase")),
"required", List.of("query"))),
Map.of(
"name", "getArticle",
"title", "Read article",
"description", "Read one Meridian article as Markdown.",
"inputSchema", Map.of(
"type", "object",
"properties", Map.of(
"id", Map.of(
"type", "string",
"description", "Article ID")),
"required", List.of("id")))));
}
}
I serve both /.well-known/mcp/server-card.json and /.well-known/mcp.json because the discovery convention is still settling and different tools have looked in different places. That is mildly annoying, but cheaper than asking agents to guess.
Agent Skills Discovery
Agent Skills are still just instruction bundles: a directory with a SKILL.md file, optional scripts, references, and assets. Cloudflare’s scanner also looks for the proposed discovery index at /.well-known/agent-skills/index.json, so Meridian publishes a tiny index and a single skill document.
The useful distinction is this: Quarkus serves the skill, but it does not execute the skill. The document teaches an agent how to use the API after the agent chooses to load it. Keep it narrow, version it with the service, and avoid stuffing policy novels into it. Agents are very good at following stale instructions with confidence.
Create src/main/java/dev/themainthread/meridian/resource/AgentSkillsResource.java:
package dev.themainthread.meridian.resource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
@Path("/.well-known/agent-skills")
public class AgentSkillsResource {
private static final String DISCOVERY_SCHEMA =
"https://schemas.agentskills.io/discovery/0.2.0/schema.json";
private static final String SKILL_MD = """
---
name: meridian
description: Search and read Meridian knowledge articles. Use when a user asks for Meridian article IDs, public article search, or Markdown article content.
---
# Meridian
Use the public search endpoint first unless the user already provided an article ID.
## Search
Call:
```text
GET https://api.meridian.dev/api/v1/articles?q={query}
```
Use returned article IDs for follow-up reads.
## Read
Call:
```text
GET https://api.meridian.dev/api/v1/articles/{id}/content
Accept: text/markdown
```
Prefer Markdown content. Do not scrape HTML unless Markdown is unavailable.
""";
@ConfigProperty(name = "meridian.api.base-url")
String apiBaseUrl;
@GET
@Path("/index.json")
@Produces(MediaType.APPLICATION_JSON)
public Response index() {
Map<String, Object> index = Map.of(
"$schema",
DISCOVERY_SCHEMA,
"skills",
List.of(Map.of(
"name",
"meridian",
"type",
"skill-md",
"description",
"Search and read Meridian knowledge articles.",
"url",
apiBaseUrl + "/.well-known/agent-skills/meridian/SKILL.md",
"digest",
sha256Digest(SKILL_MD))));
return Response.ok(index).build();
}
@GET
@Path("/meridian/SKILL.md")
@Produces("text/markdown")
public Response skill() {
return Response.ok(SKILL_MD, "text/markdown")
.header("Cache-Control", "public, max-age=3600")
.build();
}
private static String sha256Digest(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return "sha256:" + HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is required by the Java platform", e);
}
}
}
The digest is not decorative. Discovery clients can use it to notice when the served skill changed. If your skill grows beyond one small file, move to an archive artifact and validate the archive like you would any other executable input from the Internet.
CORS and Browser-Hosted Agents
If browser-hosted clients call your API, expose the headers they need to read. Keep the origins explicit for a protected API. * is fine for a public static demo and a poor default for anything with write paths.
quarkus.http.cors.enabled=true
quarkus.http.cors.origins=https://app.meridian.dev,https://inspector.modelcontextprotocol.io
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS
quarkus.http.cors.headers=Accept,Authorization,Content-Type
quarkus.http.cors.exposed-headers=Content-Signal,Link,X-Markdown-Tokens,Vary,WWW-Authenticate
quarkus.http.cors.access-control-max-age=1H
quarkus.http.cors.access-control-allow-credentials=false
The property is quarkus.http.cors.enabled, not quarkus.http.cors. The older short form appears in many examples, and examples are how configuration drift learns to travel. If your browser client uses cookies instead of bearer tokens, revisit access-control-allow-credentials; for this API, bearer tokens in the Authorization header keep the browser credential rules simpler.
Verify the Chain
Run the application locally first:
quarkus devThen check the static discovery files:
curl -i http://localhost:8080/
curl -i http://localhost:8080/robots.txt
curl -i http://localhost:8080/sitemap.xml
curl -i http://localhost:8080/llms.txtYou should see 200 OK. On /, check that the response includes Link headers for the API catalog, OpenAPI document, MCP Server Card, and Agent Skills index. For robots.txt, check that the Content-Signal line is in the body.
Check Markdown negotiation:
curl -i http://localhost:8080/api/v1/articles/intro-to-meridian/content \
-H "Accept: text/markdown"Expected headers:
HTTP/1.1 200 OK
Content-Type: text/markdown
Vary: Accept
X-Markdown-Tokens: ...
Content-Signal: search=yes, ai-input=yes, ai-train=noCheck the API catalog:
curl -i http://localhost:8080/.well-known/api-catalog \
-H "Accept: application/linkset+json"Expected headers:
HTTP/1.1 200 OK
Content-Type: application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"Check the required HEAD response as well:
curl -I http://localhost:8080/.well-known/api-catalogExpected header:
Link: <http://localhost:8080/.well-known/api-catalog>; rel="api-catalog"; type="application/linkset+json"Expected body shape:
{
"linkset": [
{
"anchor": "http://localhost:8080/api/v1",
"service-desc": [
{
"href": "http://localhost:8080/q/openapi?format=json",
"type": "application/json"
}
]
}
]
}Check protected resource metadata:
curl -i http://localhost:8080/.well-known/oauth-protected-resourceExpected body fields include:
{
"resource": "http://localhost:8080",
"authorization_servers": [
"http://localhost:8180/realms/quarkus"
],
"scopes_supported": [
"read",
"write",
"admin"
]
}The exact authorization_servers URLs depend on your Dev Services setup. When Docker is unavailable, Quarkus may start an embedded OIDC dev service instead of Keycloak; the array can contain that base URL (without a /realms/... path). In production, the same field should point at your real issuer, for example https://auth.meridian.dev/realms/meridian.
Then hit a protected endpoint without a token (your write method should use io.quarkus.security.Authenticated; there is no jakarta.annotation.security.Authenticated):
curl -i http://localhost:8080/api/v1/articles \
-X POST \
-H "Content-Type: application/json" \
-d '{"title":"Draft","content":"No token yet"}'Expected result:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="..."For MCP, use a real MCP client or MCP Inspector with a bearer token against:
http://localhost:8080/mcpFor older SSE clients, use the compatibility endpoint:
http://localhost:8080/mcp/sseDo not call the MCP endpoint with plain curl and expect a friendly REST document. It is a protocol endpoint, not a brochure.
You can still check the pre-connection discovery documents with curl:
curl -i http://localhost:8080/.well-known/mcp/server-card.json
curl -i http://localhost:8080/.well-known/mcp.json
curl -i http://localhost:8080/.well-known/agent-skills/index.json
curl -i http://localhost:8080/.well-known/agent-skills/meridian/SKILL.mdThe server card should name the streamable-http transport and point at http://localhost:8080/mcp. The skills index should contain a skills array with a skill-md entry and a sha256: digest.
What I Would Not Ship Blindly
Some adjacent ideas are worth watching, but I would not make the main tutorial depend on them.
MCP Server Cards are useful as pre-connection hints, but I would not let the card drift from the real MCP implementation. Let MCP initialize do the authoritative capability exchange and treat the card as an index.
Agent Skills are useful as packaged instructions. Keep the skill small and specific. A giant skill that restates your whole developer portal is just documentation drift with YAML frontmatter.
A2A Agent Cards matter when your service is itself an agent that other agents should delegate to. Meridian is an API with MCP tools, not an A2A server, so adding an A2A card here would be theater.
WebMCP is aimed at browser-exposed tool surfaces. It is worth watching, but it does not change this server-side Quarkus API.
x402 and other agent payment protocols are moving quickly. x402 has real momentum after the Linux Foundation announcement in April 2026, but paid API flows need product, fraud, accounting, and support decisions. Cloudflare’s live scanner also lists MPP, UCP, and ACP; that is a separate article. Maybe in the future.
Web Bot Auth is becoming more practical through Cloudflare and AWS WAF support. Use it when bot identity matters for enforcement. It is not required for the discovery flow we built here.
Summary
Making a Quarkus API easier for agents is mostly about removing guesswork.
robots.txt, sitemap.xml, llms.txt, and Link headers give clients a first map. Markdown content negotiation keeps article bodies cheap to read. Content Signals make usage intent explicit. Quarkus OIDC can publish RFC 9728 protected resource metadata when you opt in. RFC 9727 gives your OpenAPI document a proper catalog. The Quarkiverse MCP extension gives tool-capable clients a protocol endpoint, and the server card plus Agent Skills index make that tool surface easier to discover before a client connects.
None of this turns a bad API into a good one. It just makes the good parts discoverable without asking an agent to reverse-engineer your service by failing requests at it. Boring repeatability wins again.


