В уроке 114 у голема появились руки: инструменты Toolkit, обработчики слоем и цикл агента на Chat. Но руки достались только своему агенту, тому, что живёт в том же процессе и зовёт chat.generateText с нашим TowerKit. Помощник Чонси живёт в другой программе, иногда на другом языке и у другого провайдера модели. Импортировать наш Toolkit он не может.
Без общего языка выходит таблица: каждый помощник пишет свой переходник к каждому окошку. Пять помощников и четыре окошка дают двадцать переходников, и каждый ломается по-своему. Общий язык превращает таблицу в два списка: каждый помощник один раз учит язык, каждое окошко один раз на нём отвечает.
Один язык на всех окошках
Такой язык уже есть. Он называется MCP. Помощник Чонси это клиент, справочная Дорфла это сервер. Клиент подключается, спрашивает «что ты умеешь» и получает ответ, который понимает без нашего кода.
Сервер предлагает клиенту три вещи:
- Инструменты. То, что можно сделать:
tower_status, «отправь письмо». Модель клиента сама решает, когда их звать, как в цикле агента из урока 114. - Ресурсы. То, что можно прочитать: список писем на линии, расписание башен. Ресурс не действие, а документ по адресу.
- Промпты. Заготовки вопросов для человека: «спроси голема про башню T-2». Человек выбирает заготовку в меню своего помощника, сервер подставляет параметры.
Сообщения ходят в формате JSON-RPC (сам обмен покажет Пондер ниже). Руками его писать не придётся: McpServer из effect/ai собирается из тех же деталей, что и агент урока 114.
Инструменты из Toolkit
Руки Дорфла уже описаны. Для справочной хватит одной, той, что смотрит в книгу башен:
import { Effect, Layer, Schema } from 'effect';
import { McpServer, Tool, Toolkit } from 'effect/ai';
const TowerStatus = Tool.make('tower_status', {
description: 'Открыта ли башня линии Академии',
parameters: Schema.Struct({ code: Schema.String }),
success: Schema.Struct({ code: Schema.String, open: Schema.Boolean }),
failure: Schema.Struct({ code: Schema.String, reason: Schema.String }),
failureMode: 'return',
});
const GolemKit = Toolkit.make(TowerStatus);
const towers: Record<string, boolean> = { 'T-2': true, 'T-3': false };
const tools = McpServer.toolkit(GolemKit).pipe(
Layer.provide(
GolemKit.toLayer({
tower_status: ({ code }) => {
const open = towers[code];
return open === undefined
? Effect.fail({ code, reason: 'нет такой башни' })
: Effect.succeed({ code, open });
},
}),
),
);
McpServer.toolkit(GolemKit) это слой, который регистрирует инструменты набора на сервере. Обработчики он требует так же, как chat.generateText в уроке 114: слоем GolemKit.toLayer(...). Один и тот же Toolkit с одними и теми же обработчиками годится и своему агенту, и чужим помощникам. Голем не держит две пары рук.
Схемы инструмента работают дважды. parameters клиент видит в tools/list как JSON Schema (inputSchema), и той же схемой сервер декодирует аргументы, прежде чем позвать обработчик. success даёт outputSchema и structuredContent: клиент получает не только текст, но и объект, который можно проверить. Инструмент без параметров объявляют с Tool.EmptyParams: пустой Schema.Struct({}) сервер при регистрации отвергнет.
Инструмент без пометок сервер объявляет осторожно, с readOnlyHint: false и destructiveHint: true: «может менять и даже стирать», и клиент вправе спрашивать человека перед каждым вызовом. tower_status только читает книгу, скажи это: TowerStatus.annotate(Tool.Readonly, true).
Провал это тоже ответ
Горожанин спросил про T-9. Такой башни нет в книге, и обработчик вернул Effect.fail. Что увидит помощник Чонси?
{
"content": [{ "type": "text", "text": "{\"code\":\"T-9\",\"reason\":\"нет такой башни\"}" }],
"isError": true
}
Не ошибку протокола, а обычный ответ с пометкой isError: true. Ошибка протокола значит «вызов не состоялся»: нет такого инструмента. Ответ с isError значит «инструмент отработал и честно говорит, что не вышло», и модель клиента сама решает, переспросить горожанина или сказать «такой башни нет».
Случаев четыре:
| Что случилось | Что получает клиент |
|---|---|
обработчик вернул Effect.fail | ответ с isError: true, в тексте закодированный провал по схеме failure |
аргументы не прошли схему parameters | ответ с isError: true и текстом Invalid parameters for tool 'tower_status': ... |
| обработчик упал дефектом | ответ с isError: true и текстом Tool execution failed due to an internal server error.; дефект уходит в лог сервера |
| инструмента с таким именем нет | ошибка JSON-RPC с кодом -32602 |
failureMode на проводе ничего не меняет: провал уходит клиенту одинаково. Разница видна только в своём агенте урока 114, где 'return' отдаёт провал модели, а не в канал ошибок; поэтому в описании 'return'.
Где тут дыра
import { createInterface } from 'node:readline';
const towers: Record<string, boolean> = { 'T-2': true, 'T-3': false };
createInterface({ input: process.stdin }).on('line', (line) => {
const request = JSON.parse(line);
if (request.method === 'tools/call') {
const { code } = request.params.arguments;
if (!(code in towers)) throw new Error(`нет башни ${code}`);
const result = { content: [{ type: 'text', text: String(towers[code]) }] };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
}
});
На его примере всё верно: спросили T-2, сервер ответил true. Соседний пример: помощник Чонси спрашивает про T-9. throw в обработчике строки никто не ловит, процесс падает, и следующему горожанину справочная уже молчит. Строка с битым JSON роняет сервер так же, через JSON.parse.
У McpServer каждый вызов живёт в своём файбере. Провал становится ответом с isError, дефект тоже, а сервер отвечает следующему клиенту как ни в чём не бывало. Но у дефекта есть цена: клиент видит только «internal server error» и не знает причины. Поэтому ожидаемую беду, вроде «нет такой башни», объявляй в failure и возвращай через Effect.fail. Дефект оставь для того, чего не ждал никто.
Ресурсы: что можно прочитать
Помощник Липвига хочет знать, когда башни открываются. Это не действие, а документ, и для него у сервера есть ресурс:
const schedule = McpServer.resource({
uri: 'liniya://schedule',
name: 'schedule',
description: 'Расписание башен линии',
content: Effect.succeed('T-2: с 7 до 22\nT-3: закрыта на ремонт'),
});
uri это адрес, по которому клиент читает ресурс (resources/read), name и description клиент показывает в списке (resources/list). content это эффект: строка, байты или готовый результат чтения. Эффект запускается на каждое чтение, поэтому расписание в ответе всегда свежее.
Если документов много, например письма каждой башни, и они отличаются одним параметром, пригодится шаблон адреса. Параметр описывает McpSchema.param со схемой:
import { McpSchema } from 'effect/ai';
const towerLetters = McpServer.resource`liniya://letters/${McpSchema.param('tower', Schema.String)}`({
name: 'tower-letters',
content: (uri, tower) => Effect.succeed(`письма для ${tower}`),
});
Такой ресурс клиент видит не в resources/list, а в resources/templates/list с адресом liniya://letters/{tower}. Чтение liniya://letters/T-2 разбирает адрес, декодирует tower схемой и зовёт content с готовым значением.
Сервисы, которые читает content, это сервисы запроса: Layer.provide к слою ресурса их не закроет. Как быть, написано в описании McpServer.resource.
Где тут дыра
// Книга писем: у каждого письма имя и адрес отправителя, башня и текст.
declare const letterBook: Effect.Effect<ReadonlyArray<{ from: string; address: string; tower: string }>>;
const allLetters = McpServer.resource({
uri: 'liniya://letters',
name: 'letters',
content: Effect.map(letterBook, (letters) => JSON.stringify(letters)),
});
На его примере всё верно: помощник Липвига читает ресурс и видит, что T-4 завалена письмами. Соседний пример: к той же справочной подключён помощник Чонси, а в книге у каждого письма есть имя и адрес отправителя. Ресурс не знает, кто его читает. Теперь адреса половины города лежат в контексте чужой модели, а оттуда уходят дальше: в ответ покупателю, в журнал провайдера, в следующий промпт.
Ресурс видят все клиенты, которые подключились к серверу. Отдавай в нём ровно то, что нужно справочной: сколько писем ждёт у каждой башни, без имён и адресов. Если разным помощникам положено разное, проверка доступа это дело транспорта и middleware, а не надежды на то, что модель не станет читать лишнее.
Промпты: заготовка для человека
Третья вещь сервера нужна не модели, а человеку. Горожанин в лавке Чонси не обязан знать, как правильно спросить голема. Сервер даёт заготовку:
const askAboutTower = McpServer.prompt({
name: 'citizen',
description: 'Спросить голема, открыта ли башня',
parameters: { tower: Schema.String },
content: ({ tower }) => Effect.succeed(`Открыта ли башня ${tower}? Если не знаешь, так и скажи.`),
});
Клиент показывает промпт в своём меню (prompts/list) вместе с параметром tower. Человек выбирает заготовку, вводит T-2, клиент зовёт prompts/get, и сервер собирает текст. Строку из content сервер превращает в одно сообщение от пользователя (для нескольких реплик content вернёт массив сообщений).
Всё вместе это один слой регистраций:
const registrations = Layer.mergeAll(tools, schedule, askAboutTower);
Транспорт: stdio и HTTP
Регистрации описывают, что сервер умеет, а транспорт решает, как до него добраться. Готовых транспортов два.
stdio. Клиент сам запускает сервер дочерним процессом и говорит с ним строками JSON через стандартные ввод и вывод. Так подключают локальные инструменты: редактор кода запускает справочную у тебя на машине.
import { NodeRuntime, NodeStdio } from '@effect/platform-node';
import { McpProtocol } from 'effect/ai';
const stdioServer = McpServer.layerStdio({
name: 'golem',
version: '1.0.0',
protocols: [McpProtocol.v2025_11_25],
registrations,
}).pipe(Layer.provide(NodeStdio.layer));
Layer.launch(stdioServer).pipe(NodeRuntime.runMain);
layerStdio требует сервис Stdio, и в Node его даёт NodeStdio.layer. Layer.launch держит слой живым, пока программу не остановят. В stdout такого сервера нельзя писать ничего, кроме протокола: лишний console.log клиент примет за битое сообщение.
HTTP. Сервер живёт отдельно, клиенты приходят по сети. Так справочная Почтамта отвечает всем лавкам города сразу.
McpServer.layerHttp принимает те же поля и ещё path: '/mcp': он регистрирует один адрес в HttpRouter, как окошки из урока 92, а слушает порт HttpRouter.serve с NodeHttpServer.layer. Чужие сайты он пускает, только если они есть в allowedOrigins; вход по паролю или токену layerHttp на себя не берёт, его ставит HTTP-сервер вокруг.
protocols это список версий MCP, на которых сервер готов говорить. Версия 2025-11-25 держит сессию: клиент здоровается через initialize, и сервер помнит его до конца соединения. В 4.1 есть и McpProtocol.v2026_07_28 без сессии, где каждый запрос несёт всё о себе сам.
Под капотом
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"chonsi","version":"1.0.0"}}}
← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"logging":{},"tools":{"listChanged":true},"completions":{}},"serverInfo":{"name":"golem","version":"1.0.0"}}}
→ {"jsonrpc":"2.0","method":"notifications/initialized"}
→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"tower_status","description":"Открыта ли башня линии Академии","inputSchema":{...},"outputSchema":{...}}]}}
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"tower_status","arguments":{"code":"T-2"}}}
← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"code\":\"T-2\",\"open\":true}"}],"structuredContent":{"code":"T-2","open":true},"isError":false}}
В initialize клиент предлагает версию, сервер её подтверждает и перечисляет, что умеет: в этом запуске только tools, и клиент не спрашивает лишнего. notifications/initialized без id: это уведомление, ответа нет. Дальше обычные вызовы; id связывает ответ с запросом, как в RPC из урока 93.
Проверка без транспорта
Поднимать процесс ради теста не нужно. McpTest.makeClient запускает сервер в памяти, сам проходит initialize и отдаёт клиента с типизированными методами:
import { McpTest } from 'effect/ai';
const program = Effect.gen(function* () {
const client = yield* McpTest.makeClient(registrations, {
protocols: [McpProtocol.v2025_11_25],
});
const answer = yield* client.callTool({ name: 'tower_status', arguments: { code: 'T-9' } });
console.log(answer.isError); // true
const read = yield* client.readResource({ uri: 'liniya://schedule' });
console.log(read.contents); // [{ uri: 'liniya://schedule', text: 'T-2: с 7 до 22\nT-3: закрыта на ремонт' }]
}).pipe(Effect.scoped);
Запросы идут через то же кодирование JSON-RPC и те же схемы, что у stdio и HTTP, поэтому тест видит то же, что настоящий клиент, включая isError и structuredContent. Клиент и сервер живут в окружающем Scope, отсюда Effect.scoped. Ошибка протокола, вроде неизвестного инструмента, приходит в канал ошибок как McpError.
Нестабильно.
McpServer,McpTest,McpProtocol,McpSchemaи весьeffect/aiв 4.1 помечены@stability unstable: имена, опции транспортов и список версий протокола могут поменяться в минорной версии. Урок написан наeffect@4.1.0и версии протокола MCP2025-11-25.
Разминка
Справочная Дорфла открывает окошко для всех помощников. В src/mcp.ts собери сервер: инструмент tower_status по книге башен, где башня не из книги это ответ с isError, и ресурс писем по адресу liniya://letters. Тесты поднимают его через McpTest.makeClient и спрашивают, как настоящий клиент.
Практика
Глубже в архиве
Тот же протокол со стороны Python, с инструментами, ресурсом схемы и промптом для разбора ошибки, разобран в уроке про свой MCP-сервер на Python курса бэкенда.
Дальше
Дорфл стоит у окошка справочной. Помощник Чонси спрашивает про T-2, помощник Казимира про T-3, помощник Липвига читает письма. Все говорят на одном языке, и голем отвечает каждому одинаково честно. По лестнице Почтамта поднимается Рыдкулл.
Рыдкулл ведёт последний раздел курса, EF16 «Мастерство». Начинаем с урока 116.