Публикация доступного только для чтения инструмента MCP

CloudflareBeginner
Практиковаться сейчас

Введение

Клиенту с ИИ не должна требоваться отдельная интеграция для каждого используемого приложения. Model Context Protocol (MCP) предоставляет клиентам стандартный способ обнаруживать инструменты, просматривать их контракты входных данных и вызывать их. В этой лабораторной работе инструмент намеренно сделан небольшим: он выполняет поиск одного синтетического обращения в поддержку и ничего не изменяет.

Вы создадите сервер с помощью актуального stateless-обработчика MCP от Cloudflare:

  1. Выделенное пространство имён Cloudflare KV хранит синтетическую бизнес-запись. KV — это явно заданное хранилище данных приложения, а не скрытая память сеанса MCP.
  2. Строгая схема Zod принимает только идентификатор синтетического обращения и отклоняет дополнительные поля.
  3. McpServer.registerTool() публикует один инструмент с аннотациями, указывающими на доступ только для чтения и отсутствие разрушительных действий.
  4. createMcpHandler() создаёт новый сервер для каждого запроса Streamable HTTP.
  5. Официальный клиент MCP для TypeScript обнаруживает и вызывает инструмент через независимые соединения.
  6. Локальные и развёрнутые проверки подтверждают корректный поиск, безопасную обработку отсутствующей записи, отклонение недопустимых аргументов и отсутствие неявного общего состояния сеанса.

Этот endpoint намеренно не требует аутентификации, поскольку предоставляет только одну одноразовую синтетическую запись, доступную только для чтения. Не используйте этот шаблон для публикации личных данных клиентов. Перед доступом к данным арендаторов производственные серверы должны добавить аутентификацию и авторизацию; внешние провайдеры OAuth не входят в эту вводную лабораторную работу.

Ранее в экосистеме MCP использовались endpoints SSE и шаблон серверов с состоянием. В этой лабораторной работе этот устаревший дизайн не рассматривается. Здесь используются Streamable HTTP и фабрика серверов, создающая экземпляр для каждого запроса, — это текущая рекомендация Cloudflare для нового удалённого сервера.

Перед непосредственным началом этого курса пройдите лабораторную работу Подключение LabEx к вашей учётной записи Cloudflare. Для каждой новой виртуальной машины LabEx требуется отдельная авторизация Wrangler. Предыдущие лабораторные работы курса рекомендуются, но их виртуальные машины и ресурсы здесь никогда не используются повторно.

Авторизация виртуальной машины и создание выделенного каталога

На этом шаге вы авторизуете новую виртуальную машину, выберете учебную учётную запись и создадите одно одноразовое пространство имён KV. Отдельный каталог упрощает проверку владельца и очистку ресурсов.

Перейдите в подготовленный проект и проверьте закреплённые версии инструментов:

cd /home/labex/project/read-only-mcp-tool
node --version
npx wrangler --version

Авторизуйте эту виртуальную машину:

npx wrangler login

Откройте показанную ссылку для устройства в браузере, проверьте запрошенные разрешения и авторизуйте выделенную учебную учётную запись. Вернитесь в терминал, дождитесь завершения операции и просмотрите структурированные сведения об учётной записи:

Wrangler запрашивает разрешения, необходимые для управления Worker и пространством имён KV этой лабораторной работы

Список разрешений шире, чем требуется для этой лабораторной работы, поскольку Wrangler — это универсальный CLI для разработки Cloudflare. Перед подтверждением убедитесь, что на странице указано имя Wrangler, используется нужная учебная учётная запись, а в терминале не отображаются пароль или токен.

npx wrangler whoami --json

Убедитесь, что loggedIn: true и указано нужное имя учётной записи, даже если в выводе перечислена только одна учётная запись. Скопируйте фактическое значение id этой учётной записи. Сгенерируйте уникальный префикс и сохраните исходную конфигурацию Worker, предварительно заменив заполнитель:

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s07-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-19",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true }
}
JSON

Создайте пространство имён, не разрешая Wrangler автоматически изменять файл:

npx wrangler kv namespace create "$RUN-cases" --update-config=false

Если Wrangler спросит, добавлять ли привязку автоматически, выберите No. Следующее редактирование задаст эту связь явно. Скопируйте 32-символьный идентификатор пространства имён из вывода и добавьте ровно одну привязку:

NAMESPACE_ID="paste-the-created-namespace-id"
python3 - "$NAMESPACE_ID" <<'PY'
import json, sys
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data['kv_namespaces'] = [{'binding': 'SUPPORT_CASES', 'id': sys.argv[1]}]
path.write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler kv namespace list
python3 .labex/verify.py authorization

Имя привязки SUPPORT_CASES — это идентификатор, который будет использовать ваш код. Идентификатор пространства имён указывает на реальный ресурс в подтверждённой учётной записи. Пока ничего не развёрнуто.

Заполнение явными синтетическими бизнес-данными

На этом шаге вы поместите одну и ту же предоставленную запись в локальное и удалённое KV. Хранилище данных задано явно: запрос MCP может быть stateless, а приложение при этом продолжает получать долговременные бизнес-данные по ключу.

Перед загрузкой просмотрите fixture:

cat fixtures/case.json

Префикс T-SYNTH-101 и маркер synthetic: true делают границу демонстрации очевидной. Запись не содержит настоящего имени клиента, адреса электронной почты, сообщения или учётных данных.

Заполните локальное хранилище, используемое командой wrangler dev:

npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --local

Заполните выделенное облачное пространство имён:

npx wrangler kv key put case:T-SYNTH-101 --path fixtures/case.json --binding SUPPORT_CASES --remote

Прочитайте обе копии через привязку:

npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --local --text
npx wrangler kv key get case:T-SYNTH-101 --binding SUPPORT_CASES --remote --text
python3 .labex/verify.py catalog

Проверка сопоставляет пространство имён с идентификатором учётной записи, требует наличия ровно одного ключа и сравнивает удалённый JSON с предоставленным синтетическим fixture. Между расположениями KV может наблюдаться eventual consistency, поэтому, если при первом чтении сразу после записи удалённое значение временно не найдено, подождите несколько секунд и повторите чтение вместо создания повторных копий.

Регистрация строгого инструмента MCP только для чтения

На этом шаге вы определите одну фабрику сервера MCP и один инструмент поиска только для чтения.

McpServer описывает поверхность протокола. Фабрика создаёт новый экземпляр для каждого HTTP-запроса, а привязка SUPPORT_CASES остаётся явно заданным источником бизнес-данных. Создайте src/server.ts:

cat > src/server.ts <<'TS'
import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";

interface Env {
  SUPPORT_CASES: KVNamespace;
}

const lookupInput = z.object({
  ticketId: z.string().regex(/^T-SYNTH-[0-9]{3}$/, "use a synthetic ticket ID")
}).strict();

const storedCase = z.object({
  ticketId: z.string(),
  subject: z.string(),
  status: z.string(),
  priority: z.string(),
  product: z.string(),
  synthetic: z.literal(true)
}).strict();

function buildServer(env: Env): McpServer {
  const requestInstance = crypto.randomUUID();
  const server = new McpServer({
    name: "synthetic-support-catalog",
    version: "1.0.0"
  });

  server.registerTool("lookup_support_case", {
    title: "Look up a synthetic support case",
    description: "Read one synthetic demonstration case by its T-SYNTH identifier.",
    inputSchema: lookupInput,
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false
    }
  }, async ({ ticketId }) => {
    const raw = await env.SUPPORT_CASES.get(`case:${ticketId}`, "json");
    if (raw === null) {
      return {
        isError: true,
        content: [{ type: "text", text: `Synthetic case ${ticketId} was not found.` }]
      };
    }

    const record = storedCase.parse(raw);
    const result = { ...record, requestInstance };
    return {
      structuredContent: result,
      content: [{ type: "text", text: JSON.stringify(result) }]
    };
  });

  return server;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return Response.json({
        service: "synthetic-support-mcp",
        transport: "streamable-http",
        state: "stateless"
      });
    }
    if (url.pathname !== "/mcp") return new Response("Not found", { status: 404 });

    const handler = createMcpHandler(
      () => buildServer(env),
      { route: "/mcp", corsOptions: false, legacy: "stateless" }
    );
    return handler(request, env, ctx);
  }
};
TS
npm run check
python3 .labex/verify.py server

Здесь важны три границы:

  • .strict() отклоняет незаявленные поля, а не принимает их молча.
  • Аннотации сообщают клиентам, что инструмент читает закрытый синтетический каталог и не выполняет разрушительных действий. Аннотация — это полезные метаданные, но не замена проверке кода, подтверждающей отсутствие put() и delete().
  • requestInstance создаётся при построении сервера фабрикой. Разные запросы протокола должны возвращать разные маркеры. Благодаря этому жизненный цикл stateless можно наблюдать без хранения данных сеанса.

Режим совместимости legacy: "stateless" по-прежнему использует Streamable HTTP. Он позволяет современным клиентам согласовывать семейство протоколов 2025 года и одновременно гарантирует, что каждый запрос получает новый экземпляр сервера; маршрут SSE и долговременный сеанс MCP не создаются.

Создание независимой проверки клиента MCP

На этом шаге вы используете официальную библиотеку клиента вместо ручного написания JSON-RPC. Настоящий клиент выполняет инициализацию протокола, обнаружение инструментов и вызов через StreamableHTTPClientTransport.

Создайте scripts/test-client.mjs:

cat > scripts/test-client.mjs <<'JS'
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";

const endpoint = process.argv[2];
if (!endpoint) throw new Error("usage: node scripts/test-client.mjs <mcp-url>");

async function withClient(label, action) {
  const transport = new StreamableHTTPClientTransport(new URL(endpoint));
  const client = new Client({ name: `labex-${label}`, version: "1.0.0" });
  try {
    await client.connect(transport);
    return await action(client);
  } finally {
    await client.close();
  }
}

const tools = await withClient("discovery", (client) => client.listTools());
const tool = tools.tools.find((item) => item.name === "lookup_support_case");
if (!tool || tool.annotations?.readOnlyHint !== true) {
  throw new Error("the read-only lookup tool was not discoverable");
}
console.log("DISCOVERED lookup_support_case");

async function lookup(ticketId) {
  return withClient(`lookup-${ticketId.toLowerCase()}`, (client) => client.callTool({
    name: "lookup_support_case",
    arguments: { ticketId }
  }));
}

const first = await lookup("T-SYNTH-101");
const second = await lookup("T-SYNTH-101");
const a = first.structuredContent;
const b = second.structuredContent;
if (!a || !b || a.synthetic !== true || a.status !== "investigating") {
  throw new Error("the valid synthetic record was not returned");
}
console.log(`VALID synthetic=${a.synthetic} status=${a.status}`);

const missing = await lookup("T-SYNTH-404");
console.log(`MISSING isError=${missing.isError === true}`);

let invalidRejected = false;
try {
  const invalid = await withClient("invalid", (client) => client.callTool({
    name: "lookup_support_case",
    arguments: { ticketId: "REAL-101", unexpected: "must-not-pass" }
  }));
  invalidRejected = invalid.isError === true;
} catch {
  invalidRejected = true;
}
console.log(`INVALID_REJECTED ${invalidRejected}`);

const stateless = typeof a.requestInstance === "string"
  && typeof b.requestInstance === "string"
  && a.requestInstance !== b.requestInstance;
console.log(`STATELESS ${stateless}`);

if (missing.isError !== true || !invalidRejected || !stateless) process.exitCode = 1;
JS
python3 .labex/verify.py client

Каждый вызов вспомогательной функции создаёт и закрывает собственный клиентский транспорт. Обнаружение подтверждает, что сервер объявляет контракт инструмента. Два корректных вызова должны прочитать одну и ту же запись KV, но вернуть разные маркеры экземпляра запроса. Отсутствующее обращение — это обычная ошибка уровня инструмента, а недопустимый идентификатор отклоняется входной схемой до того, как обработчик прочитает KV.

Проверка контракта MCP локально

На этом шаге вы запустите Worker с локальным KV и выполните полную проверку клиента до обращения к развёрнутому endpoint.

Запустите сервер разработки:

npx wrangler dev --ip 127.0.0.1 --port 8787

Оставьте этот терминал работающим. Откройте второй терминал, перейдите в тот же проект и проверьте небольшой маршрут проверки состояния:

cd /home/labex/project/read-only-mcp-tool
curl --fail --silent http://127.0.0.1:8787/health | python3 -m json.tool

Ожидайте transport: "streamable-http" и state: "stateless". Теперь запустите клиент протокола:

node scripts/test-client.mjs http://127.0.0.1:8787/mcp

Пять строк проверки должны показать обнаружение, корректный синтетический результат, безопасную ошибку для отсутствующего обращения, отклонение недопустимого ввода и STATELESS true. Вернитесь в первый терминал и нажмите Ctrl+C после завершения проверки.

Запустите независимую проверку. Она запускает ещё один локальный Worker на порту 8791, использует тот же импортированный код и автоматически завершает его работу:

python3 .labex/verify.py local

Развёртывание и проверка удалённого endpoint MCP

На этом шаге вы развернёте Worker с явной привязкой KV и запустите тот же клиент против настоящего endpoint workers.dev.

Выполните развёртывание на основе конфигурации проекта:

npx wrangler deploy

Скопируйте показанный URL развёртывания и сохраните его без завершающей косой черты:

WORKER_URL="https://your-generated-worker.your-subdomain.workers.dev"

Проверьте маршрут состояния, затем подключите клиент MCP к /mcp:

curl --fail --silent "$WORKER_URL/health" | python3 -m json.tool
node scripts/test-client.mjs "$WORKER_URL/mcp"
python3 .labex/verify.py deployed

Независимая проверка получает endpoint из выбранной учётной записи, а не доверяет переменной оболочки. Она также проверяет развёрнутую привязку SUPPORT_CASES, точную удалённую запись и все пять вариантов поведения MCP. Одного доступного маршрута состояния недостаточно: обнаружение и вызов должны пройти через клиент протокола.

Откройте Workers & Pages и выберите созданный Worker. На странице обзора домен workers.dev должен быть связан с Worker, а также должна отображаться одна привязка KV SUPPORT_CASES. Значения ниже приведены в качестве примера из проверенного запуска; ваши уникальные имена ресурсов и их количество будут отличаться.

Развёрнутый Worker MCP подключён к одной привязке KV SUPPORT_CASES

Проверка и удаление принадлежащих вам ресурсов

На этом шаге вы проверите наблюдаемое состояние облака, а затем удалите только Worker и пространство имён KV этого запуска, пока Wrangler всё ещё авторизован.

Откройте Cloudflare Dashboard и выберите ту же учебную учётную запись. В разделе Workers & Pages откройте Worker, имя которого начинается с labex-c11-s07-. Убедитесь, что последнее развёртывание работает, наблюдаемость включена, а привязка SUPPORT_CASES указывает на идентификатор пространства имён из wrangler.jsonc.

Откройте Storage & databases > KV, выберите соответствующее пространство имён -cases и найдите case:T-SYNTH-101. Значение — это синтетический fixture; не добавляйте личную информацию. Эти представления Dashboard полезны для ориентации, но клиент и проверка остаются авторитетным функциональным подтверждением.

В представлении KV Pairs сначала отображаются точный ключ и предварительный просмотр его JSON-значения:

Выделенное пространство имён содержит только ключ синтетического обращения в поддержку

Разверните строку, чтобы связать этот ключ с полями, которые возвращает инструмент MCP. В проверяемом fixture используются status: investigating, priority: medium и synthetic: true.

Развёрнутый JSON синтетического обращения в поддержку, сохранённый в KV

Вернитесь к Worker и откройте Observability. Успешные события POST /mcp и транспортные события GET /mcp показывают, что настоящий удалённый клиент MCP достиг развёрнутого Worker. В проверенном запуске все 42 зафиксированных события завершились успешно, и ни одно не вызвало ошибку Worker; количество ваших запросов может отличаться.

Наблюдаемость Cloudflare показывает успешные удалённые запросы MCP без ошибок

Перед удалением выполните ещё одну независимую проверку состояния:

python3 .labex/verify.py observed
cat wrangler.jsonc

Подтвердите точное уникальное имя Worker и идентификатор пространства имён, затем удалите Worker:

npx wrangler delete

Если появится запрос, проверьте показанное имя Worker и ответьте y. Удалите только пространство имён, выбранное привязкой SUPPORT_CASES:

npx wrangler kv namespace delete --binding SUPPORT_CASES
npx wrangler kv namespace list
python3 .labex/verify.py deleted

Обновите списки Worker и KV в Dashboard. Оба ресурса labex-c11-s07-... должны отсутствовать, а несвязанные ресурсы должны сохраниться. Неудачный запрос к endpoint не доказывает удаление; проверка напрямую просматривает ресурсы авторизованной учётной записи.

Найдите точное сгенерированное имя Worker. Пустой результат подтверждает, что Dashboard больше его не показывает:

В разделе Workers and Pages нет проекта с именем удалённого Worker лабораторной работы

В разделе Workers KV найдите точное пространство имён -cases. Пустое состояние и значение текущего хранилища 0 B подтверждают, что одноразовый каталог также удалён из этой чистой тестовой учётной записи:

Workers KV не показывает пространство имён удалённого синтетического каталога

Отзыв авторизации этой виртуальной машины

На этом шаге вы отзовёте временную авторизацию виртуальной машины после подтверждения отсутствия ресурсов.

npx wrangler logout
npx wrangler whoami --json || true

В структурированном результате должно быть указано loggedIn: false; Wrangler также может вернуть ненулевой результат для неаутентифицированного запроса. Выход из учётной записи выполняется намеренно в последнюю очередь: для проверки удаления требуется доступ на чтение к выбранной учётной записи, а одноразовой виртуальной машине он больше не нужен.

Итоги

Вы опубликовали и удалили ограниченный сервис MCP только для чтения в Cloudflare. Вы:

  • хранили синтетические бизнес-данные в выделенном пространстве имён KV, а не в неявном состоянии сеанса MCP;
  • зарегистрировали обнаруживаемый инструмент со строгой проверкой входных данных и аннотациями доступа только для чтения;
  • предоставили его через актуальный stateless-обработчик Streamable HTTP;
  • использовали настоящий клиент MCP для обнаружения, корректного поиска, проверки отсутствующей записи и недопустимого ввода;
  • доказали, что независимые запросы получают новые экземпляры сервера, читая при этом одни и те же явно заданные данные;
  • проверили состояние Worker и KV, удалили оба принадлежащих вам ресурса и отозвали авторизацию виртуальной машины.

Главный урок проектирования заключается в том, что stateless-транспорт не означает отсутствие данных в приложении. Это означает, что запросы протокола не зависят от скрытой памяти сеанса. Долговременные бизнес-данные остаются явными, ограниченными по области действия и управляются независимо.