Skip to content
tnsaiDocs
GitHub

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.

FactoryTools 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

InterfaceImplementOptional (default)
ToolProviderlistTools(), executeTool(name, arguments)
ResourceProviderlistResources(), readResource(uri)listResourceTemplates() (none), supportsSubscription() (false), subscribe, unsubscribe
PromptProviderlistPrompts(), getPrompt(name, arguments)getCompletions(prompt, argument, value) (none)
  • executeTool returns a ToolProvider.ToolResult: text(String), success(JsonNode content) or error(message). The client gets the content and isError.
  • readResource throws ResourceProvider.ResourceNotFoundException for an unknown URI.
  • getPrompt returns PromptProvider.PromptResult.messages(list) or PromptResult.error(message). Build the messages with PromptMessage.user(text), assistant(text) or message(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 from System.in and writes to System.out. It blocks until the input ends or stop() is called. start(input, output) uses other streams, which is handy in tests.
  • isRunning() and isInitialized() report the state; processMessage(json) handles one message directly.
  • Providers can be added and removed while the server runs (addToolProvider, removeToolProvider and the resource and prompt pairs). Tell the client with the matching notification.
MethodNotification 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) answers sampling/createMessage requests sent to this server. The server advertises the sampling capability only when a handler is set; without one the request fails with "method not found". A request without messages or maxTokens is rejected as invalid.
  • clientRoots(list) is the list roots/list returns. setSamplingHandler and setClientRoots change 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.

RequestBehavior
POST with initializeCreates a session and returns its id in the Mcp-Session-Id header
POST, other requestsNeeds Mcp-Session-Id; an unknown or expired session gets 404. A notification gets 202, an empty body 400
GETA server-sent event stream for the session's notifications, with a keep-alive comment every 15 seconds
DELETEEnds the session: 204, 404 for an unknown session, 400 without the header
Any other method405

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.

CodeWhen
-32700The message is not JSON
-32600The jsonrpc version is not 2.0, or a request has no method
-32601The method is unknown, or sampling/createMessage has no handler
-32602Missing or unknown parameters, including an unknown tool name
-32603An 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.

CategoryRetriableRaised when
TOOL_NOT_FOUNDNoNo provider lists the tool
TIMEOUTYesThe exception message contains "timeout" or "timed out"
PERMISSION_DENIEDNoIt contains "permission", "denied" or "unauthorized"
INVALID_PARAMSNoThe exception is an IllegalArgumentException, or the message contains "invalid"
INTERNALYesAny 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()).