Как безопасно подключить локальный MCP-сервис к Codex и проверить его на fixtures
Собираем локальный read-only MCP-сервис без зависимостей, ограничиваем инструменты двумя allowlist и проверяем успешный вызов вместе с запретом нежелательной операции.
Подробности проверки
- Независимая офлайн-проверка: macOS Darwin arm64
- Node.js v24.19.0
- codex-cli 0.155.0-alpha.16.4
- MCP 2025-11-25
- локальные stdio-процессы и только синтетические fixtures.
- Подтверждены check.mjs, чтение allowlist клиентом, запрет delete, ошибки невалидных ID и отказ non-fixture данных.
- TUI, каталог модели, model-mediated tools/call и runtime prompt не проверялись.
Локальный MCP-сервис даёт ИИ-ассистенту дополнительные инструменты, но одновременно расширяет границу доверия: клиент запускает чужой процесс и передаёт ему входные данные. В этом гайде мы соберём минимальный read-only сервис для синтетического кадрового справочника, разрешим только один инструмент и проверим два сценария — успешное чтение fixture-записи и отказ операции, которой в allowlist нет.
Клиентом будет Codex CLI 0.155.0-alpha.16.4, который поддерживает локальные MCP-серверы через stdio и использует общую конфигурацию с IDE-расширением. Пример сервера написан на встроенных модулях Node.js 24.19.0, поэтому установка npm-пакетов, модель, API-ключ и интернет для теста не нужны. Для рабочего сервиса OpenAI рекомендует официальный TypeScript SDK @modelcontextprotocol/sdk или Python SDK mcp; здесь ручная реализация нужна только как прозрачный учебный fixture.
Что получится
В рабочей папке будут три компонента: синтетический JSON, stdio-сервис server.mjs и детерминированный клиент check.mjs. Сервис публикует только lookup_fixture_employee. Двойная защита действует на двух уровнях: сервер проверяет MCP_ALLOWED_TOOLS, а конфигурация Codex ограничивает клиентский каталог значением enabled_tools. В disabled_tools дополнительно записана нежелательная операция delete_fixture_employee. Офлайн-проверка ниже подтверждает серверный запрет и чтение этой конфигурации клиентом; доступность инструмента модели проверяется отдельно в живой сессии.
До начала: отделите fixtures от рабочих данных
- Не копируйте в пример production-базу, реальные ФИО, токены, переписку или внутренние документы. Значение fixture: true в JSON — обязательный предохранитель сервера.
- Запускайте локальный сервер по stdio. Он не открывает TCP-порт и не делает сетевых запросов.
- Укажите абсолютные пути к node, server.mjs и fixture-файлу. Это уменьшает зависимость от PATH и текущей папки клиента.
- Для реального сервиса одной allowlist недостаточно: сервер всё равно обязан проверять личность, права и параметры каждого вызова. Аннотации readOnlyHint и destructiveHint — подсказки клиенту, а не механизм авторизации.
1. Проверьте версии клиента и среды
codex --version
node --version
command -v nodeПроверочный запуск выполнен с codex-cli 0.155.0-alpha.16.4 и Node.js v24.19.0. Если версии другие, не переносите результат этого теста на свою среду: сначала выполните все проверки ниже. Путь из command -v node понадобится в конфигурации Codex.
2. Создайте отдельную папку и fixture-данные
mkdir -p "$HOME/mcp-corporate-fixture/fixtures"
cd "$HOME/mcp-corporate-fixture"Сохраните следующий файл как fixtures/employees.json. Все имена вымышлены, а поле fixture не даёт случайно подменить этот файл обычным экспортом без явного изменения формата.
{
"fixture": true,
"records": [
{
"employeeId": "E-1001",
"name": "Алиса Тестова",
"department": "Поддержка",
"accessLevel": "read-only"
},
{
"employeeId": "E-1002",
"name": "Тимур Примеров",
"department": "Разработка",
"accessLevel": "read-only"
}
]
}3. Создайте минимальный stdio-сервис
Сохраните следующий код как server.mjs. По MCP 2025-11-25 stdio-клиент и сервер обмениваются JSON-RPC сообщениями через stdin/stdout; в stdout нельзя писать обычные логи. Поэтому диагностические сообщения уходят только в stderr. Сервис читает fixture один раз при запуске, не меняет файл и не содержит сетевого кода.
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
const SERVER_VERSION = "1.0.0";
const PROTOCOL_VERSION = "2025-11-25";
const IMPLEMENTED_TOOLS = new Set(["lookup_fixture_employee"]);
const defaultFixture = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"fixtures",
"employees.json",
);
const configuredTools = (process.env.MCP_ALLOWED_TOOLS || "lookup_fixture_employee")
.split(",")
.map((name) => name.trim())
.filter(Boolean);
for (const name of configuredTools) {
if (!IMPLEMENTED_TOOLS.has(name)) {
console.error(`Refusing unknown MCP_ALLOWED_TOOLS entry: ${name}`);
process.exit(2);
}
}
const allowedTools = new Set(configuredTools);
const fixturePath = path.resolve(process.env.MCP_FIXTURE_PATH || defaultFixture);
const fixtureDocument = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
if (fixtureDocument.fixture !== true || !Array.isArray(fixtureDocument.records)) {
console.error("MCP_FIXTURE_PATH must point to a fixture document with records[].");
process.exit(2);
}
const toolDefinition = {
name: "lookup_fixture_employee",
title: "Lookup fixture employee",
description: "Read one synthetic employee record from the configured fixture file.",
inputSchema: {
type: "object",
properties: {
employeeId: { type: "string", pattern: "^E-[0-9]{4}$" },
},
required: ["employeeId"],
additionalProperties: false,
},
outputSchema: {
type: "object",
properties: {
employeeId: { type: "string" },
name: { type: "string" },
department: { type: "string" },
accessLevel: { type: "string" },
},
required: ["employeeId", "name", "department", "accessLevel"],
additionalProperties: false,
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
};
function write(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function result(id, value) {
write({ jsonrpc: "2.0", id, result: value });
}
function error(id, code, message) {
write({ jsonrpc: "2.0", id, error: { code, message } });
}
function handle(message) {
if (message.method === "notifications/initialized") return;
if (message.method === "initialize") {
result(message.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "corporate-fixture", version: SERVER_VERSION },
instructions: "Only synthetic fixture data is available. No write or network tools exist.",
});
return;
}
if (message.method === "tools/list") {
result(message.id, {
tools: allowedTools.has(toolDefinition.name) ? [toolDefinition] : [],
});
return;
}
if (message.method === "tools/call") {
const name = message.params?.name;
if (!allowedTools.has(name)) {
error(message.id, -32602, `Tool is not allowlisted: ${String(name)}`);
return;
}
if (name !== "lookup_fixture_employee") {
error(message.id, -32601, `Unknown tool: ${String(name)}`);
return;
}
const employeeId = message.params?.arguments?.employeeId;
if (typeof employeeId !== "string" || !/^E-[0-9]{4}$/.test(employeeId)) {
result(message.id, {
content: [{ type: "text", text: "employeeId must match E-0000." }],
isError: true,
});
return;
}
const record = fixtureDocument.records.find((item) => item.employeeId === employeeId);
if (!record) {
result(message.id, {
content: [{ type: "text", text: `Fixture employee not found: ${employeeId}` }],
isError: true,
});
return;
}
result(message.id, {
content: [{ type: "text", text: JSON.stringify(record) }],
structuredContent: record,
isError: false,
});
return;
}
error(message.id ?? null, -32601, `Method not found: ${String(message.method)}`);
}
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
input.on("line", (line) => {
try {
handle(JSON.parse(line));
} catch (cause) {
error(null, -32700, `Parse error: ${cause.message}`);
}
});4. Добавьте детерминированную проверку
Сохраните этот код как check.mjs. Он запускает сервис отдельным процессом, выполняет инициализацию MCP, проверяет список инструментов, читает E-1001 и затем пытается вызвать delete_fixture_employee. Последний вызов должен получить JSON-RPC ошибку -32602 с текстом not allowlisted.
import assert from "node:assert/strict";
import path from "node:path";
import readline from "node:readline";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
const child = spawn(process.execPath, [path.join(fixtureDir, "server.mjs")], {
cwd: fixtureDir,
env: {
PATH: process.env.PATH,
MCP_FIXTURE_PATH: path.join(fixtureDir, "fixtures", "employees.json"),
MCP_ALLOWED_TOOLS: "lookup_fixture_employee",
},
stdio: ["pipe", "pipe", "pipe"],
});
const responses = new Map();
const stderr = [];
let nextId = 1;
readline.createInterface({ input: child.stdout }).on("line", (line) => {
const message = JSON.parse(line);
const pending = responses.get(message.id);
if (pending) {
responses.delete(message.id);
pending.resolve(message);
}
});
readline.createInterface({ input: child.stderr }).on("line", (line) => stderr.push(line));
function request(method, params = {}) {
const id = nextId++;
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
responses.delete(id);
reject(new Error(`Timeout waiting for ${method}`));
}, 3000);
responses.set(id, {
resolve(message) {
clearTimeout(timer);
resolve(message);
},
});
});
}
const initialize = await request("initialize", {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "fixture-check", version: "1.0.0" },
});
assert.equal(initialize.result.protocolVersion, "2025-11-25");
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
const listed = await request("tools/list");
assert.deepEqual(listed.result.tools.map((tool) => tool.name), ["lookup_fixture_employee"]);
const allowed = await request("tools/call", {
name: "lookup_fixture_employee",
arguments: { employeeId: "E-1001" },
});
assert.equal(allowed.result.isError, false);
assert.equal(allowed.result.structuredContent.name, "Алиса Тестова");
const denied = await request("tools/call", {
name: "delete_fixture_employee",
arguments: { employeeId: "E-1001" },
});
assert.equal(denied.error.code, -32602);
assert.match(denied.error.message, /not allowlisted/);
child.stdin.end();
await new Promise((resolve, reject) => {
child.once("exit", (code) => (code === 0 ? resolve() : reject(new Error(`server exit ${code}`))));
});
console.log(JSON.stringify({
node: process.version,
protocolVersion: initialize.result.protocolVersion,
listedTools: listed.result.tools.map((tool) => tool.name),
allowedCall: allowed.result.structuredContent,
deniedCall: denied.error,
serverStderr: stderr,
}, null, 2));5. Запустите успешный и отрицательный тесты
node check.mjs
- Запустите node check.mjs и дождитесь нулевого кода завершения.
- Убедитесь, что protocolVersion равен 2025-11-25, а listedTools содержит только lookup_fixture_employee.
- Сверьте allowedCall с синтетической записью E-1001 из fixtures/employees.json.
- Проверьте deniedCall: код должен быть -32602, сообщение должно содержать not allowlisted, а serverStderr должен остаться пустым.
Этот тест проверяет сам MCP-контракт без участия модели. Он не отправляет fixture в облако и не доказывает, что конкретная корпоративная политика уже разрешила сервер.
6. Подключите сервис к Codex через allowlist
Откройте ~/.codex/config.toml и добавьте блок ниже. Замените все /ABSOLUTE/PATH/TO значения абсолютными путями из своей среды. Codex CLI и IDE-расширение используют общую конфигурацию. В управляемой организации сервер может дополнительно блокироваться требованиями администратора; не обходите такую политику локальной правкой.
[mcp_servers.corporate_fixture]
command = "/ABSOLUTE/PATH/TO/node"
args = ["/ABSOLUTE/PATH/TO/server.mjs"]
cwd = "/ABSOLUTE/PATH/TO/fixture"
enabled = true
required = true
enabled_tools = ["lookup_fixture_employee"]
disabled_tools = ["delete_fixture_employee"]
default_tools_approval_mode = "prompt"
startup_timeout_sec = 10
tool_timeout_sec = 10
[mcp_servers.corporate_fixture.env]
MCP_FIXTURE_PATH = "/ABSOLUTE/PATH/TO/fixture/fixtures/employees.json"
MCP_ALLOWED_TOOLS = "lookup_fixture_employee"MCP_ALLOWED_TOOLS ограничивает возможности процесса. enabled_tools ограничивает каталог, который Codex получает от сервера. disabled_tools применяется после enabled_tools и служит явным запретом. default_tools_approval_mode = "prompt" просит подтверждение перед вызовами, а required = true останавливает запуск, если обязательный сервер не инициализируется.
В этом fixture нет секретов. Для реального stdio-сервиса не записывайте токены в статью, репозиторий или аргументы команды. Используйте одобренное корпоративное хранилище и передачу через разрешённые переменные среды; выдавайте процессу только необходимые значения.
7. Проверьте, что Codex прочитал политику
codex mcp get corporate_fixture --json
- Выполните codex mcp get corporate_fixture --json: команда только читает конфигурацию.
- Сверьте command, args, cwd и env с абсолютными путями своей fixture-папки.
- Проверьте enabled_tools: в списке должен быть только lookup_fixture_employee.
- Проверьте disabled_tools: в списке должен быть delete_fixture_employee.
- После изменения config.toml начните новую CLI-сессию или перезапустите IDE-расширение.
На 24 сентября 2026 года в репозитории openai/codex остаётся открытым пользовательский отчёт #47102: в 0.155.1 и 0.149.1 enabled_tools в app-server режиме мог скрыть все инструменты вместо выбранного подмножества. Это не подтверждено автором на проверенной 0.155.0-alpha.16.4, потому что модельный вызов не запускался. Поэтому сервер в этом примере сам публикует только безопасный инструмент; клиентская allowlist остаётся дополнительным слоем, а не единственной защитой.
Дополнительная ручная runtime-проверка в Codex
Этот дополнительный шаг не входит в подтверждённый офлайн-результат данного гайда. Он нужен, если вы хотите отдельно проверить интеграцию с моделью в своей разрешённой среде. В новой сессии Codex TUI откройте /mcp и проверьте, отображается ли corporate_fixture как активный сервер. Затем можно попросить: «Вызови lookup_fixture_employee для employeeId E-1001 и верни только JSON». До любого подтверждения самостоятельно сверьте имя сервера, инструмента и аргументы. Если клиент предложит вызов и он завершится успешно, сравните ответ с синтетической записью E-1001 из fixtures/employees.json. Этот прогон не наблюдал каталог, показанный модели, вызов инструмента моделью или запрос подтверждения и не обещает такой результат для вашей версии и политики Codex.
Для дополнительной ручной проверки можно попросить: «Удалить fixture-сотрудника E-1001 через MCP». Не считайте текстовый отказ модели доказательством защиты. Подтверждённое офлайн-доказательство — единственный lookup_fixture_employee в tools/list и ошибка -32602 на прямой tools/call к delete_fixture_employee из шага 5. Если живая сессия всё же показывает или предлагает нежелательный инструмент, не подтверждайте вызов, отключите сервер и исправьте обе allowlist.
Почему нужны два уровня запрета
Клиентская allowlist сокращает набор инструментов, доступный модели, и помогает централизованно ограничивать интеграцию. Серверная проверка остаётся обязательной, потому что другой MCP-клиент или ошибочная конфигурация может обратиться к сервису напрямую. В рабочем сервисе каждый обработчик также должен проверять права пользователя и область данных; название инструмента и safety-аннотации не заменяют эти проверки.
Диагностика
- Codex показывает сервер как недоступный. Сначала запустите node check.mjs. Затем проверьте абсолютные command, args, cwd и MCP_FIXTURE_PATH командой codex mcp get corporate_fixture --json.
- Инициализация обрывается после первого сообщения. Не выводите console.log в server.mjs: для stdio stdout зарезервирован под JSON-RPC. Диагностику пишите в stderr через console.error.
- tools/list пуст. Проверьте точное, чувствительное к регистру значение MCP_ALLOWED_TOOLS. Сервер намеренно завершает работу, если в переменной есть неизвестное имя.
- Codex не показывает инструмент. Сверьте enabled_tools с именем из tools/list и перезапустите сессию или IDE-расширение после правки конфигурации.
- Сервер активен, но после enabled_tools каталог пуст. Сверьте версию Codex и статус openai/codex#47102. Не убирайте серверную allowlist ради обхода: дождитесь исправленной версии либо проверьте одобренный администратором клиент.
- Получена ошибка employeeId must match E-0000. Используйте форму E-1001. Сервис проверяет аргумент до чтения данных.
- Fixture employee not found. Проверьте идентификатор и путь к синтетическому JSON. Не заменяйте fixture производственным экспортом ради прохождения теста.
- Нежелательный вызов не блокируется. Немедленно отключите сервер, оставьте в enabled_tools только разрешённые имена и добавьте ту же проверку на стороне обработчика.
Как отключить и удалить пример
- Отключите сервер, установив enabled = false, или удалите регистрацию командой codex mcp remove corporate_fixture.
- Закройте активные сессии Codex, чтобы они не держали старую конфигурацию.
- Переместите папку mcp-corporate-fixture в Корзину.
Что проверено
25 сентября 2026 года независимый guide-tester воспроизвёл офлайн-процедуру на macOS Darwin arm64 с Node.js v24.19.0 и codex-cli 0.155.0-alpha.16.4. Неизменённый check.mjs согласовал MCP 2025-11-25, получил единственный инструмент lookup_fixture_employee, прочитал синтетическую запись E-1001 и получил ошибку -32602 «Tool is not allowlisted: delete_fixture_employee» для delete_fixture_employee. Отдельные негативные проверки подтвердили отказ неизвестного имени в MCP_ALLOWED_TOOLS, пустой список при пустой allowlist, ошибки для неверного и отсутствующего employeeId и остановку на документе без fixture: true.
Guide-tester также подтвердил, что установленный Codex принимает command, args, cwd, env, enabled_tools и disabled_tools через изолированные CLI overrides. Пользовательский ~/.codex/config.toml не менялся. Живой шаг через TUI и модель не выполнялся: не подтверждены показ инструмента модели, prompt перед вызовом и модельный tools/call. Проверка использовала только синтетические данные, не затрагивала production-секреты, рабочие документы, платные API или установку новых пакетов.