Build and Publish an MCP Server
In AI4J, "publishing an MCP Server" is not just adding a few annotations to a class — it is a complete server-side pipeline:
- Declare the capability surface with annotations
- Project local Java capabilities as MCP capabilities
- Choose a server-side transport
- Let
McpServerEnginehandle protocol requests - Decide which capabilities are actually exposed externally
This page focuses not on "can it run" but on how the current implementation actually publishes, what is already wired up, and what you still need to fill in yourself.
1. The main server-side pipeline: these classes first
If you want to understand the publish flow from the source, the main entry points are:
- Annotation layer
@McpService@McpTool@McpResource@McpPrompt
- Adapter layer
McpToolAdapterMcpResourceAdapterMcpPromptAdapter
- Protocol layer
McpServerEngine
- Transport/server layer
StdioMcpServerSseMcpServerStreamableHttpMcpServerMcpServerFactory
You can think of it as:
- Annotations declare "what local capabilities exist"
- Adapters organize capabilities into an MCP view
- The server engine routes MCP requests to local capabilities
- The server implementation runs the protocol on different transports
2. Capability declaration is not just Tool
The AI4J server side explicitly splits MCP capabilities into three categories:
- Tool
Action-oriented capability, ultimately responding to
tools/list/tools/call - Resource
Read-only content capability, responding to
resources/list/resources/read - Prompt
Template-oriented capability, responding to
prompts/list/prompts/get
Declaration looks like this:
@McpService(name = "weather-service", description = "Weather MCP service")
public class WeatherMcpService {
@McpTool(name = "query_weather", description = "Query weather by city")
public String queryWeather(@McpParameter(name = "city") String city) {
return "Weather(" + city + ")";
}
@McpResource(uri = "weather://city/{city}", name = "city-weather")
public String weatherResource(@McpResourceParameter(name = "city") String city) {
return "Resource(" + city + ")";
}
@McpPrompt(name = "weather-summary", description = "Generate weather summary")
public String weatherPrompt(@McpPromptParameter(name = "city") String city) {
return "Please summarize weather for " + city;
}
}
What matters most here is not syntax but keeping the capability roles distinct:
- Anything that can trigger side effects or actions → use Tool
- Read-only content → use Resource
- Templated prompts → use Prompt
3. When actually exposed externally, the three capability chains differ
Tool chain
tools/list and tools/call go through:
McpServerEngine.handleToolsList(...)ToolUtil.getLocalMcpTools()ToolUtil.scanMcpTools()- Convert
@McpToolmethods into an MCPinputSchema
On invocation:
McpServerEngine.handleToolsCall(...)ToolUtil.invoke(toolName, argumentsJson)- Dispatch to local MCP / Function / Gateway implementations by current tool priority
This means the Tool exposed by the current MCP Server is not an isolated system — it directly reuses AI4J's existing ToolUtil execution stack.
Resource chain
resources/list and resources/read go through:
McpServerEngine.handleResourcesList(...)McpResourceAdapter.getAllMcpResources()McpResourceAdapter.readMcpResource(uri)
There is an implementation boundary here that must be stated plainly:
McpResourceAdapterprovidesscanAndRegisterMcpResources()- But
McpServerEngineitself does not auto-trigger resource scanning
That is, if you have not explicitly completed resource registration before the service starts, resources/list may simply be empty.
Prompt chain
prompts/list and prompts/get go through:
McpServerEngine.handlePromptsList(...)McpPromptAdapter.getAllMcpPrompts()McpPromptAdapter.getMcpPrompt(name, arguments)
Like Resource, Prompt has the same current boundary:
- It has scan-and-register capability
- But it is not auto-initialized by the server engine
So in the current implementation:
- The Tool exposure chain is the most complete
- Resource / Prompt are usable, but you must ensure registration is completed before startup yourself
4. Which requests does the protocol engine actually handle
The handling entry points for Tool, Resource, and Prompt stay consistent, but how the protocol starts up depends on the transport profile.
Streamable HTTP: AUTO default and the modern branch
StreamableHttpMcpServer and McpServerFactory.ServerConfig use AUTO by default. AUTO accepts both modern requests and initialization-era Streamable HTTP requests on the same /mcp endpoint; it identifies modern requests via modern headers or _meta.
Modern requests identified as 2026-07-28 handle:
server/discovertools/list/tools/callresources/list/resources/readprompts/list/prompts/get
Each request must carry modern _meta and HTTP headers. It does not use initialize, notifications/initialized, or protocol sessions.
Initialization-era Streamable HTTP and other legacy transports
The AUTO server's same /mcp endpoint also retains initialization-era Streamable HTTP handling. STDIO, the explicit sse transport, and specific legacy Streamable HTTP profiles likewise retain session-era handling:
initializenotifications/initializedtools/list/tools/callresources/list/resources/readprompts/list/prompts/getping(only when the server transport enables it)
This shows AI4J does more than a "tool execution interface", but it also cannot turn the legacy handshake into a common prerequisite for all MCP transports.
5. The real differences between the three server-side transports
StdioMcpServer
- Sends and receives JSON-RPC over standard input/output
- Suitable for being spawned as a subprocess by a local host
- Internal
McpServerEnginefixed at2024-11-05 initializationRequired = false
It is more like an "embedded local tool process".
SseMcpServer
GET /sseestablishes the event streamPOST /messagesends MCP messages- Supports
ping initializationRequired = true- Protocol version fixed at
2024-11-05
It is more compatible with old-style SSE clients, but the endpoint model is more fragmented than Streamable HTTP.
StreamableHttpMcpServer
- Unified main endpoint
/mcp - Defaults to
McpProtocolProfile.AUTO, accepting both modern and initialization-era Streamable HTTP on the same/mcp - Modern requests use stateless
POST /mcp, returning JSON or SSE on the same request - Modern requests validate
MCP-Protocol-Version,Mcp-Method, and validateMcp-Nameand schema-declaredMcp-Param-*when needed - Modern requests provide
server/discoverand response cache hints - Does not provide MRTR or subscriptions
- You can explicitly select
McpProtocolProfile.MODERN_2026_07_28to allow only modern requests, or select a specific legacy profile to fix sessions,GET/DELETE, and the initialization handshake - The deprecated HTTP+SSE remains a separate
ssetransport, not a third branch of AUTO
If you plan to have external systems, platforms, or gateways consume it long-term, this is the server form you should prioritize right now.
6. Start the service with McpServerFactory
McpServerFactory.ServerConfig config = new McpServerFactory.ServerConfig(
"weather-server", "1.0.0"
).withPort(8081).withProtocolProfile(
McpProtocolProfile.AUTO
);
McpServer server = McpServerFactory.createServer("streamable_http", config);
server.start().join();
McpServerFactory is responsible for two things:
- Normalizing the type string
- Creating the corresponding server instance
Supported types:
stdiossestreamable_httphttpCompatibility-only alias, ultimately mapped tostreamable_http
http is only a transport type alias for streamable_http, and retains AUTO's limited Streamable HTTP compatibility semantics. Known session-era peers can pin a specific legacy profile in ServerConfig or in the client TransportConfig; the deprecated HTTP+SSE should use sse. See Streamable HTTP for details.
7. Server-side security defaults and the HTTP-layer extension
This section applies only to the sse / streamable_http HTTP servers; stdio has no network layer and is not involved in authentication, binding, or CORS.
7.1 Default binding to loopback
The default host of ServerConfig is the loopback address, not the wildcard:
ServerConfig.DEFAULT_HOST = "127.0.0.1"(default, only visible to localhost)ServerConfig.WILDCARD_HOST = "0.0.0.0"(binds all NICs; the factory logs a WARNING)
Specify explicitly when you need external exposure:
McpServerFactory.ServerConfig config = new McpServerFactory.ServerConfig("orders", "1.0.0")
.withPort(8081)
.withHost("0.0.0.0"); // Bind all NICs; McpServerFactory prints a security warning
The default port is 8080, overridable with withPort(int). The loopback default means: a freshly started MCP server is not automatically exposed to the public network unless you explicitly change the host.
7.2 Bearer Token authentication on by default
ServerConfig defaults to authEnabled = true. When no McpAuthProvider is explicitly configured, resolveAuthProvider() lazily creates a BearerTokenAuthProvider on first call, using SecureRandom to generate a random token. In other words, the HTTP/SSE server ships with Bearer Token authentication by default — it is no longer unauthenticated.
Behavior of the built-in BearerTokenAuthProvider:
- Validates the
Authorization: Bearer <token>header - Constant-time comparison, resisting timing side channels
- Generates a random token (at least 32 hex bytes) when constructed with no arguments
getToken()retrieves the token, for printing at startup or out-of-band deliverydescribe()returns a masked description (e.g.Bearer token: abcd...wxyz), for startup logs
BearerTokenAuthProvider auth = new BearerTokenAuthProvider("my-secret-token");
McpServerFactory.ServerConfig config = new McpServerFactory.ServerConfig("orders", "1.0.0")
.withPort(8081)
.withAuth(auth); // Inject a custom provider; authentication enabled
On authentication failure, the server uniformly responds with HTTP 401 and an WWW-Authenticate: Bearer header via McpHttpServerSupport.requireAuth(...). Health endpoints like /health do not require authentication; only business endpoints (/mcp) are validated.
7.3 Custom authentication SPI: McpAuthProvider
To plug in OAuth, JWT, API key, or an internal authentication system, implement the two-method McpAuthProvider SPI:
import com.sun.net.httpserver.HttpExchange;
import io.github.lnyocly.ai4j.mcp.server.McpAuthProvider;
public class JwtAuthProvider implements McpAuthProvider {
@Override
public boolean authenticate(HttpExchange exchange) {
// Read Authorization / Cookie / custom headers; true = pass, false = reject
String header = exchange.getRequestHeaders().getFirst("Authorization");
return header != null && validateJwt(header.replace("Bearer ", ""));
}
@Override
public String describe() {
return "JWT (HS256)"; // For startup logs only; do not leak secrets
}
}
Key points:
authenticate(HttpExchange)is called once per inbound HTTP request; returningfalsetriggers a 401.describe()is for startup logs only — do not output sensitive information in it.- Inject it via
withAuth(new JwtAuthProvider())— no change to the server implementation is required.
If the server already sits behind a reverse proxy or controlled network and authentication is handled upstream, you can explicitly turn it off with withNoAuth():
McpServerFactory.ServerConfig config = new McpServerFactory.ServerConfig("orders", "1.0.0")
.withPort(8081)
.withNoAuth(); // Unsafe; only for scenarios already authenticated by an upstream gateway
McpAuthProvider addresses "whether a request can come in" (authentication). Authorization policies such as tenant isolation, per-tool authorization, and quota limits still need to be filled in at the business layer.
7.4 CORS configuration
By default no CORS headers are sent (same-origin policy, safest). When cross-origin access is needed, use withCorsAllowedOrigin(...):
McpServerFactory.ServerConfig config = new McpServerFactory.ServerConfig("orders", "1.0.0")
.withPort(8081)
.withCorsAllowedOrigin("https://app.example.com"); // Specify a trusted origin
Key points:
- Passing
null(default) = same-origin, noAccess-Control-Allow-Originis sent. - Passing
"*"allows any origin, but is not recommended for token-protected endpoints. - Browser
Originvalidation (McpHttpServerSupport.isAllowedOrigin(...)) does not accept"*"as origin verification — wildcard CORS is not equivalent to origin verification. - The
Mcp-Param-*headers declared by the tool schema are appended to the CORS preflight as a syntactic allowlist, not arbitrarily reflected (see below).
7.5 The x-mcp-header extension for tool inputSchema
Modern Streamable HTTP (2026-07-28) lets a tool declare in its inputSchema: mirror a primitive-typed property as a Mcp-Param-<Name> HTTP header on a tools/call. This way proxies, gateways, and cache layers can route, cache, or authenticate by parameter without parsing the JSON body. The declaration is done by adding "x-mcp-header": "<Header-Name>" on a primitive-typed property under properties:
{
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" },
"count": { "type": "integer", "x-mcp-header": "Count" }
}
}
When calling tools/call, the client automatically derives the Mcp-Param-Region and Mcp-Param-Count headers from the parameters. This mechanism is implemented by McpHttpHeaderSupport, with the following rules (if any is violated, the tool schema is deemed illegal, skipped during tools/list, and warned):
- Only primitive types (
string/integer/boolean) underpropertiesare accepted; annotations on nested objects or arrayitemsare rejected. - The header name must be a legal HTTP token (no spaces or other illegal characters).
- Case-insensitive duplicate header names are rejected.
integermust be an exact integer and within the JavaScript safe integer range, encoded as a decimal string.- A
stringcontaining non-ASCII printable characters is encoded as=?base64?<b64>?=. - The server cross-validates the header value against the JSON-RPC request body, rather than treating the header as the single source of truth.
@McpParameter currently only generates name/description/required/defaultValue, and does not carry x-mcp-header. To use this extension, declare it directly on a custom inputSchema (e.g. a hand-built McpToolDefinition or a remote tool schema). AI4J's modern clients and servers automatically recognize and handle valid x-mcp-header.
8. In the current implementation, what is automated and what is not
What is already wired up
- Tool annotation scanning and parameter schema projected
- Tool invocation to the local execution chain
- The three server transports
- AUTO accepts both modern stateless and initialization-era Streamable HTTP on the same endpoint, plus a separate explicit SSE transport
What you still need to fill in yourself
- Resource / Prompt scan-and-register before startup
- Authentication policies, tenant isolation (note: the HTTP/SSE server's Bearer Token authentication is built in and on by default; custom authentication can go through the
McpAuthProviderSPI — see Section 7) - Server-side timeout, concurrency control, audit
- External version governance and compatibility policy
This part must be stated clearly, otherwise the docs would misrepresent "the protocol can run" as "platform capabilities are complete".
9. How to design boundaries when publishing externally
At minimum, settle these five things first:
- Namespace
- Are the
tool/resource/promptnames stable, will they conflict
- Are the
- Capability classification
- Do not cram read-only content and templates all into Tool
- Transport form
- Local host prefers
stdio - Service-style publishing prefers
streamable_http
- Local host prefers
- Version compatibility
- New parameters should stay backward compatible where possible
- Security surface
- Publishing a capability does not mean every client is allowed to call it by default
The publish layer only ensures "capabilities are reachable", not "anyone can use them freely".
10. Common failure points
tools/list is empty
Check first:
- Whether
@McpService/@McpToolare within the scan range - Whether tool names conflict
- Whether the local class can be instantiated via a no-arg constructor
resources/list or prompts/list is empty
Check first:
- Whether you actually defined
@McpResource/@McpPrompt - Whether you invoked the registration-scan logic before startup
The HTTP service starts, but the client cannot connect
Check first:
- Whether the endpoint is really
/mcp - Whether the client mixed up
streamable_httpandsse - Whether a proxy layer rewrites the path
11. Recommended minimal publishing posture
If you want to get one chain stable first, the recommended order is:
- Publish only Tool first
- Prefer
streamable_http - On the AUTO server, self-test with the modern
server/discover -> tools/list -> tools/calland the legacyinitialize -> tools/list -> tools/callflows separately; then validate HTTP+SSE via thessetransport on its own - Then add Resource / Prompt
- Finally connect
McpGatewayor an external platform
This is because the Tool chain is the most mature in the current implementation and is best suited as the first closed loop.