MCP server
tnsai-mcp serves tools, resources and prompts to MCP clients. McpServer speaks over stdio, so a client starts it as a subprocess. HttpMcpServer serves the same providers over HTTP. Both take providers: TnsAIToolProvider turns @Tool methods into MCP tools, and you implement ToolProvider, ResourceProvider or PromptProvider for anything else.
Example
package com.example.tnsai.docs;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.tnsai.annotations.Tool;
import com.tnsai.annotations.ToolParam;
import com.tnsai.mcp.model.McpToolDefinition;
import com.tnsai.mcp.model.Resource;
import com.tnsai.mcp.model.ResourceContent;
import com.tnsai.mcp.model.Root;
import com.tnsai.mcp.model.SamplingRequest;
import com.tnsai.mcp.model.SamplingResponse;
import com.tnsai.mcp.server.HttpMcpServer;
import com.tnsai.mcp.server.McpServer;
import com.tnsai.mcp.server.ResourceProvider;
import com.tnsai.mcp.server.TnsAIToolProvider;
import com.tnsai.mcp.server.ToolProvider;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/** Compilation contract for mcp/server.md. */
public final class McpServerExample {
private McpServerExample() {
}
public static final class WeatherTools {
@Tool(name = "get_forecast", description = "Get the forecast for a city")
public String forecast(@ToolParam(description = "City name") String city) {
return "Sunny in " + city;
}
}
static void serveOverStdio() {
McpServer server = McpServer.builder()
.serverName("weather")
.serverVersion("1.0.0")
.addToolProvider(TnsAIToolProvider.fromPojos(new WeatherTools()))
.build();
server.start(); // blocks until stdin closes or stop()
}
static HttpMcpServer serveOverHttp() throws IOException {
HttpMcpServer server = HttpMcpServer.builder()
.port(8080) // 0 picks a free port; read it with getPort()
.path("/mcp")
.serverName("weather")
.sessionTtl(Duration.ofMinutes(10))
.addToolProvider(TnsAIToolProvider.fromPojos(new WeatherTools()))
.build();
server.start();
return server;
}
public static final class EchoToolProvider implements ToolProvider {
@Override
public List<McpToolDefinition> listTools() {
return List.of(McpToolDefinition.builder()
.name("echo")
.description("Return the text unchanged")
.inputSchema(Map.of(
"type", "object",
"properties", Map.of("text", Map.of("type", "string")),
"required", List.of("text")))
.build());
}
@Override
public ToolResult executeTool(String name, JsonNode arguments) {
if (!arguments.hasNonNull("text")) {
return ToolResult.error("text is required"); // the client gets isError: true
}
return ToolResult.text(arguments.get("text").asText());
}
}
public static final class NotesResourceProvider implements ResourceProvider {
@Override
public List<Resource> listResources() {
return List.of(new Resource("notes://today", "Today's notes", "Plain-text notes", "text/plain"));
}
@Override
public List<ResourceContent> readResource(String uri) throws ResourceNotFoundException {
if (!"notes://today".equals(uri)) {
throw new ResourceNotFoundException(uri);
}
return List.of(ResourceContent.text(uri, "Ship the docs.", "text/plain"));
}
}
static McpServer withSamplingAndRoots(Function<String, String> llm) {
return McpServer.builder()
.serverName("assistant")
.addToolProvider(new EchoToolProvider())
.addResourceProvider(new NotesResourceProvider())
.samplingHandler((SamplingRequest request) -> {
String prompt = request.getMessages().get(request.getMessages().size() - 1)
.getContent().path("text").asText();
SamplingResponse response = new SamplingResponse();
response.setRole("assistant");
response.setModel("my-model");
response.setContent(JsonNodeFactory.instance.objectNode()
.put("type", "text")
.put("text", llm.apply(prompt)));
response.setStopReason("endTurn");
return response;
})
.clientRoots(List.of(Root.of("file:///srv/data", "Data")))
.build();
}
}Serve TnsAI tools
TnsAIToolProvider (in com.tnsai.mcp.server) lists each @Tool method under its tool name, with the parameter schema as the input schema. A tool marked idempotent also gets the idempotentHint annotation.
| Factory | Tools from |
|---|---|
fromPojos(Object... pojos) | The @Tool methods of the objects |
fromDynamic(DynamicToolMethod... tools), fromDynamic(List<DynamicToolMethod>) | Tools defined at runtime |
from(Collection<?> pojos, Collection<DynamicToolMethod> tools) | Both; either collection may be null |
A call returns the method's result as text: a String as is, null as an empty string, anything else as JSON. The provider turns every failure into an error result instead of throwing: an unknown name, arguments that are not a JSON object, and an exception from the method (Tool execution failed: <message>). getToolCount() and getToolNames() report what it serves.
Write a provider
| Interface | Implement | Optional (default) |
|---|---|---|
ToolProvider | listTools(), executeTool(name, arguments) | |
ResourceProvider | listResources(), readResource(uri) | listResourceTemplates() (none), supportsSubscription() (false), subscribe, unsubscribe |
PromptProvider | listPrompts(), getPrompt(name, arguments) | getCompletions(prompt, argument, value) (none) |
executeToolreturns aToolProvider.ToolResult:text(String),success(JsonNode content)orerror(message). The client gets the content andisError.readResourcethrowsResourceProvider.ResourceNotFoundExceptionfor an unknown URI.getPromptreturnsPromptProvider.PromptResult.messages(list)orPromptResult.error(message). Build the messages withPromptMessage.user(text),assistant(text)ormessage(role, content).
The server finds a tool's provider by name across all tool providers. A resource subscription is advertised when any resource provider supports it.
Stdio server
McpServer.builder() takes serverName (default TnsAI-MCP-Server), serverVersion (default 1.0.0), the add…Provider methods, samplingHandler and clientRoots.
start()reads JSON-RPC lines fromSystem.inand writes toSystem.out. It blocks until the input ends orstop()is called.start(input, output)uses other streams, which is handy in tests.isRunning()andisInitialized()report the state;processMessage(json)handles one message directly.- Providers can be added and removed while the server runs (
addToolProvider,removeToolProviderand the resource and prompt pairs). Tell the client with the matching notification.
| Method | Notification sent |
|---|---|
sendToolsListChangedNotification() | notifications/tools/list_changed |
sendResourcesListChangedNotification() | notifications/resources/list_changed |
sendResourceUpdatedNotification(uri) | notifications/resources/updated |
sendPromptsListChangedNotification() | notifications/prompts/list_changed |
sendRootsListChangedNotification() | notifications/roots/list_changed |
sendLogNotification(message), emitLogMessage(message) | notifications/message |
Write nothing else to standard output while a stdio server runs: the client reads every line as a message.
Sampling and roots
samplingHandler(fn)answerssampling/createMessagerequests sent to this server. The server advertises thesamplingcapability only when a handler is set; without one the request fails with "method not found". A request withoutmessagesormaxTokensis rejected as invalid.clientRoots(list)is the listroots/listreturns.setSamplingHandlerandsetClientRootschange them later.
HTTP server
HttpMcpServer serves one endpoint on the JDK HTTP server. Its builder takes port (default 0, a free port), path (default /mcp), serverName, serverVersion, sessionTtl (default 30 minutes) and the add…Provider methods. It has no sampling handler or roots option. build() binds the port and throws IOException when it cannot; getPort() returns the bound port.
| Request | Behavior |
|---|---|
POST with initialize | Creates a session and returns its id in the Mcp-Session-Id header |
POST, other requests | Needs Mcp-Session-Id; an unknown or expired session gets 404. A notification gets 202, an empty body 400 |
GET | A server-sent event stream for the session's notifications, with a keep-alive comment every 15 seconds |
DELETE | Ends the session: 204, 404 for an unknown session, 400 without the header |
| Any other method | 405 |
sendNotification(sessionId, method, params) queues a notification for one session and broadcastNotification(method, params) for all of them. stop() closes the server and its sessions.
HttpMcpServer checks no credentials. Put it behind a proxy or gateway that authenticates callers before you expose it beyond the local machine.
Errors
A request the server cannot handle gets a JSON-RPC error.
| Code | When |
|---|---|
| -32700 | The message is not JSON |
| -32600 | The jsonrpc version is not 2.0, or a request has no method |
| -32601 | The method is unknown, or sampling/createMessage has no handler |
| -32602 | Missing or unknown parameters, including an unknown tool name |
| -32603 | An exception, including one thrown by a provider's executeTool |
Every method except initialize and ping needs a completed initialize first.
A tool failure is also reported as a ToolErrorEvent (tool name, category, message, timestamp, request id) to the request handler's ToolErrorListeners.
| Category | Retriable | Raised when |
|---|---|---|
TOOL_NOT_FOUND | No | No provider lists the tool |
TIMEOUT | Yes | The exception message contains "timeout" or "timed out" |
PERMISSION_DENIED | No | It contains "permission", "denied" or "unauthorized" |
INVALID_PARAMS | No | The exception is an IllegalArgumentException, or the message contains "invalid" |
INTERNAL | Yes | Any other exception, or a ToolResult.error result |
The checks run in this order on the exception message. TnsAIToolProvider never throws, so its failures are always INTERNAL. Neither server builder takes a listener: McpServer keeps its request handler private, and HttpMcpServer exposes it only through a live session (getSessionManager().getSession(id).getRequestHandler()).