Новости об искусственном интеллекте
Модели ИИ
Сервисы
Исследования
The Time AI

Новости искусственного интеллекта

Инструкции/ Разработка

Как безопасно подключить локальный 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 не проверялись.
Ряд серверных стоек в вычислительном центре
Фото: CSIRO

Локальный 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. Проверьте версии клиента и среды

shell
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-данные

shell
mkdir -p "$HOME/mcp-corporate-fixture/fixtures"
cd "$HOME/mcp-corporate-fixture"

Сохраните следующий файл как fixtures/employees.json. Все имена вымышлены, а поле fixture не даёт случайно подменить этот файл обычным экспортом без явного изменения формата.

json
{
  "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 один раз при запуске, не меняет файл и не содержит сетевого кода.

javascript
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.

javascript
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. Запустите успешный и отрицательный тесты

shell
node check.mjs
Терминальный вывод check.mjs: один разрешённый инструмент, успешное чтение E-1001 и ошибка -32602 для запрещённого вызова.
Фото: TheTimeAI
Открыть крупнее
  1. Запустите node check.mjs и дождитесь нулевого кода завершения.
  2. Убедитесь, что protocolVersion равен 2025-11-25, а listedTools содержит только lookup_fixture_employee.
  3. Сверьте allowedCall с синтетической записью E-1001 из fixtures/employees.json.
  4. Проверьте deniedCall: код должен быть -32602, сообщение должно содержать not allowlisted, а serverStderr должен остаться пустым.

Этот тест проверяет сам MCP-контракт без участия модели. Он не отправляет fixture в облако и не доказывает, что конкретная корпоративная политика уже разрешила сервер.

6. Подключите сервис к Codex через allowlist

Откройте ~/.codex/config.toml и добавьте блок ниже. Замените все /ABSOLUTE/PATH/TO значения абсолютными путями из своей среды. Codex CLI и IDE-расширение используют общую конфигурацию. В управляемой организации сервер может дополнительно блокироваться требованиями администратора; не обходите такую политику локальной правкой.

toml
[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 прочитал политику

shell
codex mcp get corporate_fixture --json
Вывод codex mcp get для corporate_fixture с enabled_tools lookup_fixture_employee и disabled_tools delete_fixture_employee.
Фото: TheTimeAI
Открыть крупнее
  1. Выполните codex mcp get corporate_fixture --json: команда только читает конфигурацию.
  2. Сверьте command, args, cwd и env с абсолютными путями своей fixture-папки.
  3. Проверьте enabled_tools: в списке должен быть только lookup_fixture_employee.
  4. Проверьте disabled_tools: в списке должен быть delete_fixture_employee.
  5. После изменения 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 только разрешённые имена и добавьте ту же проверку на стороне обработчика.

Как отключить и удалить пример

  1. Отключите сервер, установив enabled = false, или удалите регистрацию командой codex mcp remove corporate_fixture.
  2. Закройте активные сессии Codex, чтобы они не держали старую конфигурацию.
  3. Переместите папку 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 или установку новых пакетов.

Поделиться

ВКонтактеTelegramWhatsApp

К другим инструкциям