Redouble AI Nucleo documentation
Part 10 · Beyond the process

Using tools from MCP servers

ai.redouble.nucleo.mcp · nucleo-core

Some tools you do not write, because they already run somewhere else: a vendor's document parser, a chemistry toolkit, a colleague's service. Many such programs speak the Model Context Protocol (MCP), an open standard, created by Anthropic, for offering tools to AI applications. A program that offers tools this way is an MCP server; it lists its tools, each with a name, a description and a JSON Schema for its arguments, and runs one when a client asks. Any application that speaks the protocol can use them without code written for that server.

Use an MCP server's tools when the operation already lives in another process and its owner publishes it over MCP. Your agent then calls it like any tool of its own: the call is a job, admitted and recorded like every other, and its result reaches the model as an artifact, data the model cannot alter (Artifacts). When the operation is yours, in your process, write it as a tool (Tools, thinkers and doers); when outside clients should call your tools, offer them from your process with Serving tools over MCP.

This package is the client side: it connects to servers, turns what they offer into tools a thinker (an agent whose model decides which tool to call next) can be given, and manages the connections as the finite resources they are. Everything it needs, the official MCP Java SDK included, comes with nucleo-core.

Giving a server's tools to an agent

Describe how to reach the server, attach it under a short name, and add what it offers to a thinker:

HTTPMCPEndpoint endpoint = new HTTPMCPEndpoint();
endpoint.setUrl("https://mcp.example.com/v1");
endpoint.setFlavor(HTTPMCPEndpoint.Flavor.STREAMABLE_HTTP);
endpoint.setHeaders(Map.of("Authorization", "Bearer " + apiKey));

MCPConnector connector = MCPConnectorRegistry.attach(new MCPHandle("github"), endpoint);
for (MCPToolProvider provider : connector.providers()) {
    thinker.addTool(provider);   // LLM-facing name: "github_<tool>", schema normalized
}
// ... when nothing needs the server any more:
MCPConnectorRegistry.detach(connector);

The endpoint says how to connect: its URL and headers for a server running as a service (HTTPMCPEndpoint), or the command that starts it as a subprocess (STDIOMCPEndpoint, below).

The handle is the short name the model knows the server's tools by. Every tool is shown to the model as the handle, an underscore and the tool's own name, so two servers that both offer search stay apart. A handle is lower case, starts with a letter, and has at most 16 letters, digits, underscores or dashes; anything else is refused when it is created.

Attaching connects to the server and fetches its list of tools, once. Each entry of connector.providers() is one remote tool, ready to hand to a thinker. Its arguments schema is the server's own, lightly normalized so every model provider accepts it, and its description is cut to McpSettings.descMaxChars characters. A tool whose schema is neither an object nor a map of properties is left out with a warning, and the rest of the server's tools are kept. A server that cannot be reached gives no tools and a warning in the log, and the next providers() call tries again; after a failed connection the endpoint fails fast for two minutes instead of waiting on another handshake.

Detaching hands back what attach returned. Everything attached to one endpoint shares one connection, which is closed when the last of it detaches.

A tool that one server type runs against several isolated backends at once, such as a filesystem server per sandbox, is attached once per backend under the same handle: what tells the attaches apart is the endpoint (a different URL, headers or environment), so the handle stays the tool-name prefix the model reads.

Choosing a transport

MCP runs over two transports. They look identical to the agent and to the tool author, and their operational profiles diverge sharply.

STDIOHTTP
Server lifecycleSubprocess of the JVM, lives and dies with our processIndependent service (any host)
Wire protocolstdin/stdout pipesTCP + HTTP/SSE
Multiple clients per serverNo - 1:1 with the parent processYes
Runtime deps on our boxWhatever the server needs (uv, python, native libraries, libreoffice, ...)None - just an HTTP client
Operational surfaceWe own the subprocess: memory, crashes, orphan reaping, file descriptorsServer runs as its own service with its own monitoring and scaling
Cold start cost per endpoint~1-2s spawn, then warm reuseOne-time TCP handshake
Best fitBenchmarks, dedicated single-machine runs, local development, CLI-only serversProduction webapps, customer deployments, sporadic traffic

Default to HTTP for production-bound integrations. STDIO inside a long-running webapp means the application server forks subprocesses, which operations then has to support: extra runtime dependencies on the box, process limits to monitor, orphan recovery, a pool of background workers running alongside the webapp. HTTP keeps the MCP server's lifecycle apart from the JVM, and the server deploys as a normal service with its own image, health checks and scaling.

STDIO is the right call when the workload is hot (benchmark runs, tight agent loops where many tool calls keep subprocesses warm), the deployment is single-machine and dedicated, or no HTTP variant of the server exists. An endpoint idle for McpSettings.stdioIdleTimeoutSeconds (300 by default) has its subprocess stopped, and its next use starts a fresh one, so an idle application eventually has no MCP processes resident.

An HTTP server

HTTPMCPEndpoint takes the server's URL, the headers every request carries, and the flavor of HTTP transport the server speaks: STREAMABLE_HTTP, the current one, or SSE, the original HTTP+SSE transport, which is the endpoint's default. Match what the server advertises.

A server that authenticates agents with the MCP authorization flow takes a credential in place of a header: setCredential with an AgentSecret or an AgentSigningKey, on the streamable flavor, and the endpoint obtains and renews its tokens itself (MCP authorization). Two endpoints to one URL under different credentials or headers are separate connections, so one consumer never rides another's session.

A call to an HTTP server waits 20 seconds for its answer, the SDK's default and the right one for stateless servers.

A server started as a subprocess

STDIOMCPEndpoint endpoint = new STDIOMCPEndpoint();
endpoint.setName("github");
endpoint.setCommand("npx");
endpoint.setArgs(List.of("-y", "@modelcontextprotocol/server-github"));
endpoint.setEnvironment(Map.of("GITHUB_TOKEN", token));

The name is required: it labels the endpoint in health snapshots and logs (mcp:github), and an endpoint without one fails fast, saying the name is missing. The environment is part of the endpoint's identity, so two endpoints that start the same command under different environments, such as different sandbox roots, run as two subprocesses.

A subprocess server that legitimately needs longer than 20 seconds to answer, such as one driving a browser through heavy pages, gets setRequestTimeout; raising it for stateless servers only costs them their fast failure.

Subprocesses are limited twice, and admission takes both before a call runs, at once or not at all: at most McpSettings.stdioPoolMax (20) in use across the process, and at most McpSettings.stdioMaxConcurrentPerEndpoint (10) per endpoint. STDIOEndpointPool.setEndpointMax(endpointId, max) sets a different limit for one endpoint; its id is the command and its arguments.

What comes back

A call returns an MCPToolResult. The model sees it as one artifact: an MCPArtifact holding the server's structured response, in which an object larger than 2000 bytes becomes a nested artifact the model sees by reference, and a string longer than 1000 characters is shown shortened while the whole value stays available. A model that needs the full value asks for it by reference through the artifact tools (Artifact tools).

When the server is itself a Nucleo process and a tool's whole result is an artifact, or a list of them, it arrives as the type it was sent as, as far as this process holds that type: the exact class, else the nearest ancestor it holds, else the generic MCPArtifact.

Your own code reads the raw content of a result with getContent(), or its text with getTextContent() (the first text item) and getAllTextContent() (every text item).

When a remote call fails

A failure reaches the model and your code as one of the runtime's own exceptions, which say whether a corrected call can succeed (Failures that behave):

MCP ErrorFramework ExceptionCorrectable?
Connection/spawn failureExternalServiceException("MCP:endpointId", ...)No
Protocol error (JSON-RPC)ExternalServiceException("MCP:endpointId", ...)No
Server rejects credentials (handshake or call)UnauthorizedException("MCP:endpointId", ...)No
Tool returned isError=trueInvalidInputException(toolName, endpointId, ...)Yes
Client not connectedExternalServiceException("MCP:endpointId", ...)No

The server is the authority on its own arguments: nothing is validated before the call, and a server's refusal comes back as the correctable InvalidInputException, so the model can correct its arguments and call again.

Calling a server from your own code

Code that calls a server directly, without a model, takes a client from the pool, which keeps one connected client per endpoint:

MCPClient github = MCPClientPool.getConnectedClient(endpoint);
List<MCPToolDescriptor> tools = github.listTools();
MCPToolResult result = github.callTool("search_repositories",
    objectMapper.valueToTree(Map.of("query", "language:java stars:>1000")));
// No close() - the pool manages the client.

GenericMCPClient.connect(endpoint) connects a client of your own instead, which you close when done; closing a subprocess server's client stops the subprocess.

A server used heavily earns a typed wrapper: a tool class per remote tool with typed input and output beans, its own @ToolName, and the same endpoint underneath, reading the result from MCPToolResult.getContent(). The generic path above serves any server used directly.

Introducing your application

Every connection introduces itself to the server in the protocol's handshake, and servers log and meter by that name. It belongs to your application: call GenericMCPClient.identifyAs(name, version) once at startup. Until then the runtime presents itself as nucleo at the library's version.

Settings

The package's settings, on McpSettings, assigned by the deployment's configurator or bound from nucleo.mcp.* properties in a Spring Boot or Quarkus application (Settings and the configurator):

FieldDescriptionDefault
stdioPoolMaxGlobal max concurrent STDIO processes20
stdioMaxConcurrentPerEndpointMax concurrent per endpoint10
stdioIdleTimeoutSecondsEvict an idle endpoint's client after300 (5 min)
descMaxCharsTruncation cap on a remote tool's description surfaced to the LLM4096

Shutting down

MCPConnectorRegistry.shutdownAll() detaches every connector, and STDIOEndpointPool.shutdown() stops the idle-subprocess sweep and closes every cached client, which stops their subprocesses. Neither the Spring Boot starter nor the Quarkus extension calls them, so an application that uses MCP servers calls both in its own shutdown.

Example: Calling an MCP server - attach, list, and call a tool generically; then An MCP tool wrapped as your own, the typed wrapper a workflow depends on.

How it works inside

The resource accounts, the connector registry's reference counting, the exact interfaces, the result walker and the schema normalizer are in Inside the MCP client, for those working on the runtime itself.

^