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):
docsctl (from docsctl schema --json) |
MCP tool or result field | Notes |
|---|---|---|
A subcommand such as |
Tool |
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 |
|
Rewrite it for a model when the human summary is terse (see
Self-Describing Help); the command name gives |
The command’s |
|
A JSON Schema object. Flags the wrapper injects itself ( |
|
|
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 |
|
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 |
A non-zero exit code |
|
See the next section. |
The |
|
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):
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}}
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.
| Exit code | Meaning | Tool result |
|---|---|---|
0 |
Success, including an idempotent no-op and a dry-run plan |
|
2 |
Usage error |
|
3 |
Validation rejected the input |
|
4 |
Not found |
|
5 |
Confirmation required |
|
6 |
Retryable |
|
7 |
Authentication missing or invalid |
|
Timeout or output over the cap |
Not an exit code of the CLI |
The wrapper kills the process and returns |
126, 127, or 128 plus a signal number |
Set by a shell or the kernel, not emitted by |
Treat as an internal failure: |
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:
safety key |
Annotation | What the annotation means to a host |
|---|---|---|
|
|
The tool does not modify its environment. |
|
|
The tool may perform destructive updates. Only meaningful when it is not read-only. |
|
|
Calling it again with the same arguments has no additional effect. Only meaningful when it is not read-only. |
|
|
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.
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_docsctlis the single place that starts a process. It builds nothing from strings: the argument list is a Python list,shell=Falseis spelled out,stdinis closed, the environment is rebuilt from scratch, andtimeoutkills a hung child and raisesTimeoutExpired, which becomes a message the model can act on. -
The tool functions validate before they build the list (
checked_namerejects anything that is not a plain name, and so anything starting with-) and put--immediately before the free-textquery. A query such as-h kalmanreachesdocsctlas an operand, not as a flag. -
Errors are raised as
ToolError, which FastMCP returns as a result withisError: trueand the given message. FastMCP’s documentedToolResulttakescontent,structured_contentandmeta; this wrapper does not depend on any other parameter. -
The
annotationsdictionary carries all four hints, andoutput_schemais 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.
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.
@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.
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.
The API is documented in the Spring AI MCP server annotations reference and ProcessBuilder.
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/listshows the annotations you meant, and that thestructuredContentvalidates against theoutputSchema. The MCP Inspector and the client libraries for this are on Testing & Debugging, and testing the CLI itself is covered on Testing CLIs.