Wrapping a CLI as an MCP Server

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the 2026-07-28 revision of the MCP specification, fastmcp 4.0.10 on Python 3.12, @modelcontextprotocol/server 2.2.0 with Zod 4.6.5 on Node.js 24 and TypeScript 7.0.2, and Spring AI 2.0.1 on Spring Boot 4.1.1 and Java 21 (the baseline of Model Context Protocol), together with the docsctl running example of this sub-section, as checked on 2026-09-30. docsctl is fictional, so the listings run against a small stub that prints the output shapes of the contract. The Python and TypeScript servers were exercised in memory with their own MCP clients, the TypeScript one was also type-checked, and the Java classes were compiled and their methods called directly; none of them was connected to a real host, and the Spring AI classes were not run inside a Spring context. Re-check the linked API pages before relying on a detail.

A CLI that already follows the rules of this sub-section — JSON on stdout, a command tree that describes itself, exit codes that mean something, a dry-run and confirmation pattern — is most of an MCP server already. The wrapper’s job is translation, not invention: it publishes the CLI’s commands as tools, runs the binary for each call, and turns the outcome into an MCP tool result. The reasons to do that, and the reasons not to, are on CLI vs. MCP; the servers themselves are covered in depth on Python MCP Servers, Node.js MCP Servers and Spring Boot MCP Servers, and this page does not repeat them.

The trust boundary

Read this first, because everything below depends on it. The wrapper is a local process that turns arguments chosen by a language model into a command line, and it runs with the privileges of whoever started it. Two parties are on the other side of the boundary: the model, which may have been steered by text it read (see MCP Security), and any other process that can reach the server. The wrapper therefore treats every tool argument as untrusted input and every byte the CLI prints as untrusted output. The design rules that follow are defended in detail on Security; in short:

  • run the binary from a fixed absolute path, with an argument list and no shell;

  • accept only the subcommands and flags the tool needs, and validate each value before it reaches the argument list;

  • put -- before user-supplied operands, and reject values that start with -;

  • pass a minimal environment, with only the credential the CLI needs;

  • enforce a timeout and an output-size cap;

  • never expose commands that change credentials (login, logout) and never confirm a destructive action on the model’s behalf.

From command tree to tools

Each subcommand becomes one tool, and the pieces of the docsctl contract line up with the pieces of an MCP tool definition (see the MCP tools specification):

Table 1. How docsctl concepts map to MCP tool fields
docsctl (from docsctl schema --json) MCP tool or result field Notes

A subcommand such as search or publish

Tool name, docsctl_search and docsctl_publish

Snake case, hyphens become underscores, prefixed with the CLI name so tools from several servers stay distinct. MCP names should use only letters, digits, underscore, hyphen and dot.

The command’s summary

description

Rewrite it for a model when the human summary is terse (see Self-Describing Help); the command name gives title.

The command’s args and flags, with types, enums and defaults

inputSchema

A JSON Schema object. Flags the wrapper injects itself (--json, --no-input) do not appear, and flags it refuses to expose (--force, --confirm-token, --yes) do not appear either.

output.schema, the URL of the JSON Schema of the stdout document

outputSchema

MCP wants a JSON Schema object inline. Fetch the schema at build time, or once at start-up, and embed it; do not send the URL.

JSON on stdout when the exit code is 0

structuredContent, plus the same JSON as a text block in content

The specification says a tool that returns structured content should also return the serialised JSON in a text block, and that the server must return content that conforms to the outputSchema.

A non-zero exit code

isError: true with a text block that explains the failure

See the next section.

The safety object

annotations

See Safety metadata to annotations.

The metadata does not have to be written twice. docsctl schema --json describes the whole tree, so a small generator can read it and produce the tool definitions at build time. This is the shape of one command in the tree and the tool derived from it (abridged):

One command from docsctl schema --json
{"name": "search", "summary": "Search the documentation",
 "args": [{"name": "query", "required": true, "type": "string"}],
 "flags": [{"name": "--component", "type": "string", "required": false},
           {"name": "--limit", "type": "integer", "required": false, "default": 10}],
 "output": {"schema": "https://example.invalid/docsctl/schemas/search.v1.json"},
 "exitCodes": [0, 2, 6, 7],
 "safety": {"readOnly": true, "destructive": false, "idempotent": true, "openWorld": true, "requiresConfirmation": false}}
The derived tool as tools/list reports it (abridged)
{"name": "docsctl_search",
 "description": "Search the documentation of this site and return the best matching pages.",
 "inputSchema": {"type": "object",
                 "properties": {"query": {"type": "string", "minLength": 1, "maxLength": 200},
                                "component": {"type": "string"},
                                "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 10}},
                 "required": ["query"]},
 "outputSchema": {"type": "object", "required": ["schemaVersion", "results"], "...": "..."},
 "annotations": {"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": true}}

Generation should produce a reviewed artefact, not a live bridge. A wrapper that reads whatever commands the binary reports at run time and exposes them all has no allowlist: a new subcommand, or a compromised binary, becomes a new tool without anyone having looked at it. Generate the definitions, commit them, review the diff when the CLI changes, and keep the allowlist of exposed commands and flags in the wrapper. The design guidance for the tools themselves — few, narrow, well-described — is on Designing Good MCP Servers and Tool Design.

Exit codes to results

The MCP specification separates two kinds of failure. A protocol error is a JSON-RPC error for a request that is malformed or names an unknown tool. A tool execution error is a normal result with isError: true, and clients should give it to the model so it can correct itself. A failure of the CLI, including one caused by arguments the model chose, is the second kind.

Table 2. Mapping docsctl exit codes to MCP results
Exit code Meaning Tool result

0

Success, including an idempotent no-op and a dry-run plan

structuredContent is the parsed stdout document, and content carries the same JSON as text.

2

Usage error

isError: true. The message is the CLI’s error envelope, so the model sees which argument was wrong. Usually the wrapper’s own validation catches this first.

3

Validation rejected the input

isError: true with the envelope; the problems are actionable.

4

Not found

isError: true; the model can list components and try again.

5

Confirmation required

isError: true with the envelope, which carries the exact command that would confirm. The wrapper never runs it: the confirmation belongs to a person (see Confirmation stays with a person).

6

Retryable

isError: true, with the envelope’s retryable flag and any retryAfterSeconds left visible so the model or the host can back off.

7

Authentication missing or invalid

isError: true; the envelope’s hint names docsctl login and $DOCSCTL_TOKEN. The wrapper does not attempt to log in.

Timeout or output over the cap

Not an exit code of the CLI

The wrapper kills the process and returns isError: true with a message that says how to narrow the request.

126, 127, or 128 plus a signal number

Set by a shell or the kernel, not emitted by docsctl

Treat as an internal failure: isError: true, no output forwarded beyond a short message.

flowchart TD Call["tools/call docsctl_search"] --> Validate{"arguments valid?"} Validate -- no --> Err1["isError: true, validation message"] Validate -- yes --> Run["run docsctl with an argv list, timeout and output cap"] Run --> Exit{"outcome"} Exit -- "exit 0" --> Ok["structuredContent = parsed stdout, content = same JSON as text"] Exit -- "exit 5" --> Confirm["isError: true, envelope with the confirm command, never run"] Exit -- "exit 6" --> Retry["isError: true, retryable envelope"] Exit -- "exit 7" --> Auth["isError: true, auth hint"] Exit -- "other, timeout, over cap" --> Err2["isError: true, short message"]

The envelope on stderr is what makes this cheap. Because the CLI already prints {"error": {"code": …​, "message": …​, "hint": …​, "retryable": …​}}, the wrapper forwards it verbatim instead of inventing messages, and the hint reaches the model unchanged (see Streams & Exit Codes and Agent Ergonomics). Forward it truncated: stderr is untrusted output too.

Safety metadata to annotations

The safety object in the command tree was designed for this step. MCP tool annotations are hints about behaviour, and the mapping is one to one:

Table 3. Mapping docsctl safety metadata to MCP tool annotations
safety key Annotation What the annotation means to a host

readOnly

readOnlyHint

The tool does not modify its environment.

destructive

destructiveHint

The tool may perform destructive updates. Only meaningful when it is not read-only.

idempotent

idempotentHint

Calling it again with the same arguments has no additional effect. Only meaningful when it is not read-only.

openWorld

openWorldHint

The tool interacts with an open world of external entities, for example the network, rather than a closed set.

Set every hint explicitly. A tool that leaves a hint out is described by pessimistic defaults, and the Spring AI server, for one, was observed advertising destructiveHint: true for a read-only tool that did not say otherwise (see Spring Boot MCP Servers). For docsctl, search, show, list-components and validate are read-only and idempotent; publish is destructive (it overwrites the published site) and idempotent with the same idempotency key.

The specification is explicit that a client must consider annotations untrusted unless they come from a trusted server. They help a host decide what to prompt for; they are not a permission system. The controls that actually stop a destructive call are in the wrapper and in the CLI: the allowlist, the dry-run default and the confirmation envelope. requiresConfirmation has no MCP annotation, which is one more reason to keep it enforced in code.

Confirmation stays with a person

docsctl publish is a good test of the design. The wrapper below exposes it with dry_run defaulting to true, so an unattended call produces a plan. With dry_run false the CLI exits 5 and prints the confirm command, and the wrapper hands that command back as an error. It does not expose --confirm-token, --force or --yes, so a model cannot confirm its own request, and a person runs the confirming command in a terminal, or a host confirms through an approval prompt or MCP elicitation that the wrapper does not fake. The pattern is described on Safety & Idempotency; host-side approval is on MCP Primitives and Permissions & Settings.

Python with FastMCP

The standalone FastMCP 4 keeps the decorator API that the official SDK’s MCPServer also has (see Python MCP Servers for the differences and for when to choose which). The wrapper below uses fastmcp because its @mcp.tool decorator takes annotations and an explicit output_schema, and because ToolResult gives control over content and structured_content. It runs the CLI with subprocess.run and an argument list, shell=False and a timeout.

server.py — docsctl as an MCP server (fastmcp 4.0.10)
"""docsctl as an MCP server (FastMCP 4): one CLI subcommand per tool."""
import json
import os
import re
import subprocess
from typing import Annotated

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.tools import ToolResult
from pydantic import Field

DOCSCTL = os.environ.get("DOCSCTL_BIN", "/usr/local/bin/docsctl")  # absolute path, fixed at deploy time
MAX_STDOUT_BYTES = 256_000                                        # output-size cap
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")              # cannot start with "-"

SEARCH_OUTPUT = {  # in a real build: generated from `docsctl schema --json` (output.schema)
    "type": "object",
    "properties": {
        "schemaVersion": {"const": 1},
        "query": {"type": "string"},
        "total": {"type": "integer"},
        "nextCursor": {"type": ["string", "null"]},
        "truncated": {"type": "boolean"},
        "results": {"type": "array", "items": {"type": "object"}},
    },
    "required": ["schemaVersion", "results"],
}

mcp = FastMCP("docsctl", version="0.1.0")


def checked_name(value: str) -> str:
    """Allowlist validation: reject anything that is not a plain name (and so anything starting with "-")."""
    if not NAME.fullmatch(value):
        raise ToolError(f"invalid component name {value!r}: letters, digits, '.', '_' and '-' only, not starting with '-'")
    return value


def run_docsctl(argv: list[str]) -> ToolResult:
    """Run docsctl with an argv list (never a shell string) and map its result to MCP."""
    env = {"PATH": "/usr/bin:/bin", "DOCSCTL_NO_INPUT": "1"}       # minimal environment
    if "DOCSCTL_TOKEN" in os.environ:                              # pass only what the CLI needs
        env["DOCSCTL_TOKEN"] = os.environ["DOCSCTL_TOKEN"]
    try:
        proc = subprocess.run([DOCSCTL, *argv], shell=False, capture_output=True, text=True,
                              timeout=25, env=env, stdin=subprocess.DEVNULL)
    except subprocess.TimeoutExpired:
        raise ToolError("docsctl timed out after 25 s; retry with a narrower query.")
    if len(proc.stdout) > MAX_STDOUT_BYTES:
        raise ToolError("docsctl output exceeded the size cap; use --limit or a narrower query.")
    if proc.returncode == 0:
        doc = json.loads(proc.stdout)
        return ToolResult(content=proc.stdout, structured_content=doc)   # JSON stdout -> structuredContent
    # exit code != 0 -> isError: pass the CLI's own error envelope (it carries hint and confirm command)
    raise ToolError(f"docsctl exited {proc.returncode}: {proc.stderr.strip()[:4000]}")


@mcp.tool(annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True,
                       "openWorldHint": True},
          output_schema=SEARCH_OUTPUT)
def docsctl_search(
    query: Annotated[str, Field(min_length=1, max_length=200, description="Free-text query")],
    component: Annotated[str | None, Field(description="Restrict to one component")] = None,
    limit: Annotated[int, Field(ge=1, le=100)] = 10,
) -> ToolResult:
    """Search the documentation of this site and return the best matching pages."""
    argv = ["search", "--json", "--no-input", "--limit", str(limit)]
    if component:
        argv += ["--component", checked_name(component)]
    return run_docsctl([*argv, "--", query])                       # "--" before the user operand


@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True,
                       "openWorldHint": True})
def docsctl_publish(
    component: Annotated[str, Field(description="Component to publish")],
    dry_run: bool = True,
) -> ToolResult:
    """Plan (default) or attempt to publish a component; a real publish needs a human to confirm."""
    argv = ["publish", "--json", "--no-input", "--component", checked_name(component)]
    if dry_run:
        argv.append("--dry-run")
    return run_docsctl(argv)                                       # exit 5 -> isError with the confirm command


if __name__ == "__main__":
    mcp.run()                                                      # stdio

Points worth noting:

  • run_docsctl is the single place that starts a process. It builds nothing from strings: the argument list is a Python list, shell=False is spelled out, stdin is closed, the environment is rebuilt from scratch, and timeout kills a hung child and raises TimeoutExpired, which becomes a message the model can act on.

  • The tool functions validate before they build the list (checked_name rejects anything that is not a plain name, and so anything starting with -) and put -- immediately before the free-text query. A query such as -h kalman reaches docsctl as an operand, not as a flag.

  • Errors are raised as ToolError, which FastMCP returns as a result with isError: true and the given message. FastMCP’s documented ToolResult takes content, structured_content and meta; this wrapper does not depend on any other parameter.

  • The annotations dictionary carries all four hints, and output_schema is a hand-written excerpt here. In a generated build it is the embedded schema of the command.

  • The size cap is applied after the read, which bounds what is forwarded but not what is buffered; a wrapper for a chatty command should read incrementally, as the security page shows.

The API is documented on FastMCP — Tools, and process handling on Python — subprocess.

Calling the tools with the in-memory FastMCP client, against the stub
import asyncio
from fastmcp import Client
import server

async def main():
    async with Client(server.mcp) as client:
        ok = await client.call_tool("docsctl_search", {"query": "-h kalman"})
        print(ok.structured_content["results"])          # []  the leading "-h" was treated as an operand
        bad = await client.call_tool("docsctl_search", {"query": "x", "component": "--evil"}, raise_on_error=False)
        print(bad.is_error, bad.content[0].text)         # True invalid component name '--evil': ...

asyncio.run(main())

TypeScript with registerTool and execFile

The TypeScript SDK’s high-level server is McpServer from @modelcontextprotocol/server, and a tool is added with registerTool(name, config, handler) (see Node.js MCP Servers). The Node.js function for running a program without a shell is child_process.execFile, which takes the file and an array of arguments, does not use a shell by default, and supports timeout, maxBuffer (the output cap) and env.

server.ts — docsctl as an MCP server (@modelcontextprotocol/server 2.2.0)
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { McpServer, type CallToolResult } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const run = promisify(execFile);
const DOCSCTL = process.env.DOCSCTL_BIN ?? '/usr/local/bin/docsctl'; // absolute path, fixed at deploy time
const MAX_BYTES = 256_000;                                           // output-size cap (execFile maxBuffer)
const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;                    // cannot start with "-"

const searchOutput = z.object({
    schemaVersion: z.literal(1),
    query: z.string(),
    total: z.number().int(),
    nextCursor: z.string().nullable(),
    truncated: z.boolean(),
    results: z.array(z.record(z.string(), z.unknown()))
});

async function runDocsctl(argv: string[]): Promise<CallToolResult> {
    const env: Record<string, string> = { PATH: '/usr/bin:/bin', DOCSCTL_NO_INPUT: '1' };  // minimal environment
    if (process.env.DOCSCTL_TOKEN) env.DOCSCTL_TOKEN = process.env.DOCSCTL_TOKEN;
    try {
        const { stdout } = await run(DOCSCTL, argv, { timeout: 25_000, maxBuffer: MAX_BYTES, env, shell: false });
        return { content: [{ type: 'text', text: stdout }], structuredContent: JSON.parse(stdout) };  // JSON stdout -> structuredContent
    } catch (err) {
        const e = err as { code?: number | string; killed?: boolean; stderr?: string };
        const why = e.killed ? 'timed out' : `exited ${e.code}`;
        // non-zero exit -> isError: the CLI's error envelope (hint, confirm command) is the message
        return { isError: true, content: [{ type: 'text', text: `docsctl ${why}: ${(e.stderr ?? '').slice(0, 4000)}` }] };
    }
}

export function createDocsctlServer(): McpServer {
    const server = new McpServer({ name: 'docsctl', version: '0.1.0' });

    server.registerTool(
        'docsctl_search',
        {
            title: 'Search the documentation',
            description: 'Search the documentation of this site and return the best matching pages.',
            inputSchema: z.object({
                query: z.string().min(1).max(200),
                component: z.string().regex(NAME).optional(),
                limit: z.number().int().min(1).max(100).default(10)
            }),
            outputSchema: searchOutput,
            annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
        },
        async ({ query, component, limit }) => {
            const argv = ['search', '--json', '--no-input', '--limit', String(limit)];
            if (component) argv.push('--component', component);
            return runDocsctl([...argv, '--', query]);              // "--" before the user operand
        }
    );

    return server;
}

The Zod schema does double duty: it validates the arguments (the regex on component rejects --evil before the handler runs, and the SDK reports it as an error result the model can read) and it becomes the inputSchema. maxBuffer makes Node stop the child and reject when the output is too large, so the cap bounds memory as well as what is forwarded. A handler that returns isError: true does not throw; it returns a normal result, which is what the specification calls a tool execution error. Serve it with serveStdio(createDocsctlServer) from @modelcontextprotocol/server/stdio, as on the Node.js page, and write diagnostics to standard error only, never to standard output.

The relevant references are the MCP TypeScript SDK and Node.js — child_process.

Spring AI with @McpTool and ProcessBuilder

Spring AI’s MCP server scans beans for @McpTool methods (see Spring Boot MCP Servers for the starters, the protocol modes and the Java protocol-version caveat). The Java way to run a program without a shell is ProcessBuilder with a List<String>: the first element is the program and each further element is one argument, so nothing is parsed by a shell. Process.waitFor(long, TimeUnit) enforces the timeout, and destroyForcibly() ends a child that overruns the timeout or the output cap.

DocsctlTools.java — docsctl as an MCP server (Spring AI 2.0.1, Java 21)
package com.example;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;

@Component
public class DocsctlTools {

    private static final String DOCSCTL = System.getProperty("docsctl.bin", "/usr/local/bin/docsctl"); // absolute path
    private static final int MAX_STDOUT_BYTES = 256_000;                                               // output-size cap
    private static final int MAX_STDERR_BYTES = 4_000;                                                 // kept for the error message
    private static final Pattern NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");            // cannot start with "-"
    private static final JsonMapper JSON = JsonMapper.builder().build();

    public record SearchResult(int schemaVersion, String query, long total, String nextCursor,
                               boolean truncated, List<Map<String, Object>> results) {}

    @McpTool(name = "docsctl_search",
            description = "Search the documentation of this site and return the best matching pages.",
            generateOutputSchema = true,
            annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false,
                    idempotentHint = true, openWorldHint = true))
    public SearchResult search(
            @McpToolParam(description = "Free-text query", required = true) String query,
            @McpToolParam(description = "Restrict to one component", required = false) String component,
            @McpToolParam(description = "Maximum number of results, 1 to 100", required = false) Integer limit) {
        int n = limit == null ? 10 : limit;
        if (n < 1 || n > 100 || query == null || query.isBlank() || query.length() > 200) {
            throw new IllegalArgumentException("query must be 1-200 characters and limit 1-100");
        }
        List<String> argv = new ArrayList<>(List.of(DOCSCTL, "search", "--json", "--no-input", "--limit", String.valueOf(n)));
        if (component != null) {
            if (!NAME.matcher(component).matches()) {
                throw new IllegalArgumentException("invalid component name");
            }
            argv.addAll(List.of("--component", component));
        }
        argv.addAll(List.of("--", query));                       // "--" before the user operand
        return JSON.readValue(runDocsctl(argv), SearchResult.class);   // JSON stdout -> structured content
    }

    static String runDocsctl(List<String> argv) {
        try {
            return start(argv);
        } catch (IOException e) {                                // a RuntimeException becomes isError: true
            throw new IllegalStateException("could not start docsctl: " + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("interrupted while waiting for docsctl", e);
        }
    }

    private static String start(List<String> argv) throws IOException, InterruptedException {
        ProcessBuilder pb = new ProcessBuilder(argv);            // an argv list, never "sh -c <string>"
        pb.environment().clear();                                // minimal environment
        pb.environment().put("PATH", "/usr/bin:/bin");
        pb.environment().put("DOCSCTL_NO_INPUT", "1");
        String token = System.getenv("DOCSCTL_TOKEN");
        if (token != null) {
            pb.environment().put("DOCSCTL_TOKEN", token);        // pass only what the CLI needs
        }
        Process p = pb.start();
        p.getOutputStream().close();                             // no stdin
        CompletableFuture<byte[]> out = CompletableFuture.supplyAsync(() -> readOrKill(p, MAX_STDOUT_BYTES));
        CompletableFuture<byte[]> err = CompletableFuture.supplyAsync(() -> readAndDiscard(p.getErrorStream(), MAX_STDERR_BYTES));
        if (!p.waitFor(25, TimeUnit.SECONDS)) {
            kill(p);
            throw new IllegalStateException("docsctl timed out after 25 s; retry with a narrower query");
        }
        byte[] stdout = out.join();
        if (stdout.length > MAX_STDOUT_BYTES) {                  // readOrKill has already stopped the child
            throw new IllegalStateException("docsctl output exceeded the size cap; use a smaller limit");
        }
        if (p.exitValue() != 0) {                                // non-zero exit -> RuntimeException -> isError: true
            throw new IllegalStateException("docsctl exited " + p.exitValue() + ": "
                    + new String(err.join(), StandardCharsets.UTF_8).strip());
        }
        return new String(stdout, StandardCharsets.UTF_8);
    }

    /** Reads at most max + 1 bytes of stdout; one byte more than the cap means "too large", so the child is killed. */
    private static byte[] readOrKill(Process p, int max) {
        try {
            byte[] data = p.getInputStream().readNBytes(max + 1);
            if (data.length > max) {
                kill(p);                                         // stop a runaway producer instead of blocking it
            }
            return data;
        } catch (IOException e) {
            return new byte[0];
        }
    }

    /** Keeps the first max bytes of stderr and discards the rest, so a chatty child never blocks on a full pipe. */
    private static byte[] readAndDiscard(InputStream in, int max) {
        try {
            byte[] head = in.readNBytes(max);
            in.transferTo(OutputStream.nullOutputStream());
            return head;
        } catch (IOException e) {
            return new byte[0];
        }
    }

    private static void kill(Process p) {
        p.descendants().forEach(ProcessHandle::destroyForcibly); // grandchildren first, while they are still descendants
        p.destroyForcibly();
    }
}

Four details matter here. First, generateOutputSchema = true derives the outputSchema from the SearchResult record and returns the record as structuredContent; a hand-written record is a cheaper stand-in for the embedded schema in a small wrapper, and it doubles as the parsing target. Second, a RuntimeException becomes a result with isError: true, but a checked exception declared on the method fails the call instead of reaching the model, so runDocsctl catches IOException and InterruptedException and rethrows an unchecked exception (restoring the interrupt flag first). Third, the environment is cleared before PATH and the two variables the CLI needs are added; ProcessBuilder starts with a copy of the parent’s environment, which would otherwise include every secret the server process holds. Fourth, both pipes are read while the child runs, and neither reader may stop early: a child writing to a full pipe blocks until its reader takes the bytes. readOrKill reads at most MAX_STDOUT_BYTES + 1 bytes, and when it gets the extra byte it kills the child and its descendants (descendants() first, while they are still descendants, then destroyForcibly()), so an oversized result ends at once with the size-cap error instead of blocking until the 25-second timeout. readAndDiscard keeps the first 4,000 bytes of standard error for the error message and drains the rest, so a chatty but healthy child is not stopped. This mirrors safe_run.py on Security. Compiled with Java 25 (the Spring and Jackson annotations stubbed out) and run against /usr/bin/yes, the corrected code returned the size-cap error after about 25 ms; the earlier version of this snippet, which stopped reading at the cap without killing the child, waited for the full 25-second timeout, and so did a child writing 5 MB to standard error. The Jackson mapper is Jackson 3 (tools.jackson), which is what Spring Boot 4 uses.

Testing the wrapper

A wrapper has three places to be wrong, and each one has a cheap test.

  • The translation. Run the same call through the CLI and through the tool, and compare the CLI’s stdout document with the structuredContent. Add a case for each row of the exit-code table using the CLI’s own failure switches or a stub.

  • The boundary. Send the hostile values: a query that starts with -, a component name that starts with --, one with a semicolon, one that is 10 MB long, and a call that outlives the timeout. The expected result is an error result and no process running with an unexpected argument.

  • The schema. Check that tools/list shows the annotations you meant, and that the structuredContent validates against the outputSchema. The MCP Inspector and the client libraries for this are on Testing & Debugging, and testing the CLI itself is covered on Testing CLIs.