Для локального MCP используйте stdio дисциплинированно
Локально host запускает server как дочерний процесс и общается с ним через stdio. Тонкость в том, что stdin и stdout целиком принадлежат протокольным кадрам JSON-RPC - поэтому один случайный console.log способен сломать соединение, подмешав мусор в поток сообщений. Весь диагностический вывод идёт в stderr, и это не стилистика, а условие работоспособности.
Сервер запускается helper-ом, который умеет обслуживать и современных, и legacy-пиров, а ошибки пишет именно в stderr.
export function startStdioServer(): void {
serveStdio(() => createDeveloperMcpServer(), {
legacy: "serve",
onerror: (error) => console.error("MCP server error:", error)
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
startStdioServer();
}Host, в свою очередь, поднимает дочерний процесс и открывает к нему транспорт.
export async function runMcpDemo(): Promise<McpDemoResult> {
const serverScript = fileURLToPath(new URL("./server.ts", import.meta.url));
const transport = new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", serverScript],
cwd: process.cwd(),
stderr: "pipe"
});
const client = new Client(
{ name: "developer-release-client", version: "1.0.0" },
{
versionNegotiation: { mode: "auto" },
enforceStrictCapabilities: true,
defaultCacheTtlMs: 5_000
}
);Такой транспорт хорош не везде, и границы применимости стоит очертить сразу. Stdio уместен для IDE-плагина, desktop-host, локального CLI - когда сервер установлен рядом с host и работает с локальным workspace. Он не годится, когда клиентов много и они ходят по сети, когда нужно независимое масштабирование, browser-origin или централизованная OAuth-авторизация - там начинается удалённый транспорт, о котором отдельная глава.
Пока же для локального сценария есть несколько операционных правил, нарушение которых стоит дороже всего.
- На stdout - только протокольные сообщения. Для логов - stderr.
- Секреты передавайте через окружение процесса только от доверенного launcher и минимальным набором.
- Закрывайте дочерний процесс при disconnect, timeout и завершении host.
- Не принимайте произвольную командную строку от модели: исполняемый файл и аргументы задаёт доверенная конфигурация.
- В integration-тесте действительно spawn-ьте процесс - иначе вы не проверяете framing и shutdown.
Классическая поломка.console.log("Server started")на stdout выглядит для клиента как мусор вместо JSON-RPC. В companion-проектеonerrorиспользуетconsole.error, то есть stderr - и соединение остаётся чистым.
Сервер мы запускаем - теперь напишем клиента, который его находит и вызывает. И здесь главное слово - discovery.