읽기 전용 MCP 도구 게시

CloudflareBeginner
지금 연습하기

소개

AI 클라이언트가 사용하는 모든 애플리케이션에 맞춰 별도의 통합 기능을 만들 필요는 없습니다. **Model Context Protocol(MCP)**은 클라이언트가 도구를 검색하고, 입력 계약을 확인하고, 도구를 호출할 수 있는 표준 방법을 제공합니다. 이 실습에서는 의도적으로 작은 도구를 만듭니다. 합성 지원 사례 하나를 조회하며, 어떤 데이터도 변경하지 않습니다.

Cloudflare의 현재 stateless MCP 핸들러를 사용해 서버를 구축합니다.

  1. 전용 Cloudflare KV 네임스페이스에 합성 비즈니스 레코드를 저장합니다. KV는 명시적인 애플리케이션 데이터 저장소이며, 숨겨진 MCP 세션 메모리가 아닙니다.
  2. 엄격한 Zod 스키마로 합성 티켓 식별자만 허용하고 추가 필드는 거부합니다.
  3. McpServer.registerTool()로 읽기 전용이며 파괴적이지 않은 주석이 포함된 도구 하나를 게시합니다.
  4. createMcpHandler()로 각 Streamable HTTP 요청마다 새로운 서버를 생성합니다.
  5. 공식 MCP TypeScript 클라이언트로 독립적인 연결에서 도구를 검색하고 호출합니다.
  6. 로컬 및 배포된 프로브를 사용해 정상 조회, 안전한 레코드 없음 처리, 잘못된 인수 거부, 암시적인 공유 세션 상태의 부재를 확인합니다.

이 엔드포인트는 일회용 합성 읽기 전용 레코드 하나만 노출하므로 인증을 사용하지 않습니다. 이 패턴으로 실제 고객의 비공개 데이터를 게시하지 마세요. 프로덕션 서버는 테넌트 데이터에 접근하기 전에 인증과 권한 부여를 추가해야 합니다. 외부 OAuth 공급자는 이 초급 실습의 범위에 포함되지 않습니다.

MCP 생태계에서는 이전에 SSE 엔드포인트와 상태 저장 서버 보일러플레이트를 사용했습니다. 이 실습에서는 해당 레거시 설계를 다루지 않습니다. 새로운 원격 서버에 대한 현재 Cloudflare 지침에 따라 Streamable HTTP와 요청별 서버 팩토리를 사용합니다.

이 과정을 직접 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료하세요. 새로 생성된 모든 LabEx VM에는 자체 Wrangler 인증이 필요합니다. 이전 과정의 실습을 먼저 진행하는 것이 좋지만, 해당 실습의 VM과 리소스는 이 실습에서 재사용되지 않습니다.

VM 인증 및 전용 카탈로그 생성

이 단계에서는 새 VM을 인증하고, 학습에 사용할 계정을 선택한 다음, 일회용 KV 네임스페이스를 하나 생성합니다. 카탈로그를 별도로 관리하면 소유권과 정리 대상을 명확하게 구분할 수 있습니다.

준비된 프로젝트로 이동해 고정된 도구 버전을 확인합니다.

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

이 VM을 인증합니다.

npx wrangler login

브라우저에서 표시된 디바이스 링크를 열고 요청된 권한을 검토한 다음, 전용 학습 계정을 인증합니다. 터미널로 돌아와 인증이 완료될 때까지 기다린 후 구조화된 ID 정보를 확인합니다.

실습 Worker와 KV 네임스페이스를 관리하는 데 필요한 권한을 Wrangler가 요청합니다

이 권한 목록이 이 실습 하나에 필요한 범위보다 넓은 것은 Wrangler가 Cloudflare의 범용 개발 CLI이기 때문입니다. 승인하기 전에 페이지에 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자 네임스페이스 ID를 복사하고, 정확히 하나의 바인딩을 추가합니다.

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 바인딩 이름은 코드에서 사용할 식별자입니다. 네임스페이스 ID는 인증한 계정에 있는 실제 리소스를 가리킵니다. 아직 아무것도 배포하지 않았습니다.

명시적인 합성 비즈니스 데이터 입력

이 단계에서는 제공된 동일한 레코드를 로컬 KV와 원격 KV에 저장합니다. 데이터 저장소는 명시적으로 지정됩니다. MCP 요청은 stateless로 처리하면서도 애플리케이션은 키를 사용해 영구적인 비즈니스 데이터를 읽을 수 있습니다.

업로드하기 전에 픽스처를 확인합니다.

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

검증기는 계정 ID로 네임스페이스를 확인하고, 키가 정확히 하나만 있는지 검사하며, 원격 JSON을 제공된 합성 픽스처와 비교합니다. KV는 위치 간에 최종적 일관성을 사용하므로 방금 작성한 값이 첫 번째 원격 읽기에서 잠시 나타나지 않을 수 있습니다. 이 경우 복사본을 반복해서 작성하지 말고 몇 초 기다린 후 다시 시도합니다.

엄격한 읽기 전용 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 계약 실행

이 단계에서는 로컬 KV를 사용해 Worker를 시작하고, 배포된 엔드포인트에 접근하기 전에 전체 클라이언트 프로브를 실행합니다.

개발 서버를 시작합니다.

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를 누릅니다.

독립적인 검사를 실행합니다. 이 검사는 포트 8791에서 제한된 별도의 로컬 Worker를 시작하고, 동일하게 가져온 코드를 실행한 다음 자동으로 종료합니다.

python3 .labex/verify.py local

원격 MCP 엔드포인트 배포 및 테스트

이 단계에서는 명시적인 KV 바인딩과 함께 Worker를 배포하고, 실제 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

독립적인 검증기는 셸 변수의 값을 신뢰하지 않고 선택한 계정에서 엔드포인트를 계산합니다. 또한 배포된 SUPPORT_CASES 바인딩, 정확한 원격 레코드, MCP의 다섯 가지 동작을 모두 확인합니다. 상태 확인 경로에 연결되는 것만으로는 충분하지 않습니다. 검색과 호출도 프로토콜 클라이언트를 통해 성공해야 합니다.

Workers & Pages를 열고 생성된 Worker를 선택합니다. 개요 화면에서 workers.dev 도메인이 Worker에 연결되어 있고 SUPPORT_CASES KV 바인딩 하나가 표시되는지 확인합니다. 아래 값은 테스트 실행에서 사용한 예시이므로, 사용자의 고유한 리소스 이름과 개수는 다릅니다.

하나의 SUPPORT_CASES KV 바인딩에 연결된 배포된 MCP Worker

소유한 리소스 확인 및 삭제

이 단계에서는 클라우드에서 확인 가능한 상태를 살펴본 다음, Wrangler 인증이 유지된 상태에서 이번 실행에 해당하는 Worker와 KV 네임스페이스만 삭제합니다.

Cloudflare Dashboard를 열고 같은 학습 계정을 선택합니다. Workers & Pages에서 이름이 labex-c11-s07-로 시작하는 Worker를 엽니다. 최신 배포가 정상인지, 관측 기능이 활성화되어 있는지, SUPPORT_CASES 바인딩이 wrangler.jsonc의 네임스페이스 ID를 가리키는지 확인합니다.

Storage & databases > KV를 열고 해당하는 -cases 네임스페이스를 선택한 다음 case:T-SYNTH-101을 확인합니다. 값은 합성 픽스처입니다. 개인 정보를 추가하지 마세요. 이러한 Dashboard 화면은 환경을 파악하는 데 유용하지만, 기능 동작을 판단하는 공식 근거는 클라이언트와 검증기입니다.

KV Pairs 화면에는 먼저 정확한 키와 JSON 값의 미리보기가 표시됩니다.

전용 네임스페이스에 합성 지원 사례 키만 포함되어 있습니다

행을 확장해 해당 키와 MCP 도구가 반환하는 필드를 연결해 확인합니다. 테스트에 사용한 픽스처에는 status: investigating, priority: medium, synthetic: true가 포함되어 있습니다.

KV에 저장된 확장된 합성 지원 사례 JSON

Worker로 돌아가 Observability를 엽니다. 성공한 POST /mcp 및 전송 GET /mcp 이벤트를 통해 실제 원격 MCP 클라이언트가 배포된 Worker에 연결되었음을 확인할 수 있습니다. 테스트 실행에서는 캡처된 이벤트 42개가 모두 성공했고 Worker 오류는 하나도 없었습니다. 사용자의 요청 수는 다를 수 있습니다.

Cloudflare 관측 화면에 오류 없이 성공한 원격 MCP 요청이 표시됩니다

삭제하기 전에 독립적인 관찰 검사를 한 번 더 실행합니다.

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

정확한 고유 Worker 이름과 네임스페이스 ID를 확인한 다음 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

Dashboard의 Worker 목록과 KV 목록을 새로 고칩니다. labex-c11-s07-... 리소스 두 개가 모두 사라지고, 관련 없는 리소스는 그대로 있어야 합니다. 엔드포인트 요청이 실패했다는 사실만으로는 삭제를 증명할 수 없습니다. 검증기는 인증된 계정의 리소스 목록을 직접 확인합니다.

정확하게 생성된 Worker 이름을 검색합니다. 검색 결과가 없으면 Dashboard에서 해당 Worker가 더 이상 표시되지 않는 것입니다.

Workers and Pages에 삭제된 실습 Worker와 일치하는 프로젝트가 표시되지 않습니다

Workers KV에서 정확한 -cases 네임스페이스를 검색합니다. 빈 상태와 0 B 현재 저장 용량이 표시되면 일회용 카탈로그도 이 테스트 계정에서 삭제된 것입니다.

Workers KV에 삭제된 합성 카탈로그와 일치하는 네임스페이스가 표시되지 않습니다

이 VM의 인증 해제

이 단계에서는 리소스가 없는 것을 확인한 후 임시 VM 인증을 해제합니다.

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

구조화된 결과에 loggedIn: false가 표시되어야 합니다. 또는 Wrangler가 인증되지 않은 상태를 나타내는 0이 아닌 종료 코드를 반환할 수 있습니다. 로그아웃을 마지막에 수행하는 이유는 삭제 확인에 선택한 계정에 대한 읽기 권한이 필요하지만, 일회용 VM에는 더 이상 필요하지 않기 때문입니다.

요약

Cloudflare에서 범위가 제한된 읽기 전용 MCP 서비스를 게시하고 삭제했습니다. 다음 작업을 수행했습니다.

  • 암시적인 MCP 세션 상태 대신 전용 KV 네임스페이스에 합성 비즈니스 데이터를 저장했습니다.
  • 엄격한 입력 검증과 읽기 전용 주석이 포함된 검색 가능한 도구를 등록했습니다.
  • 현재의 stateless Streamable HTTP 핸들러를 통해 도구를 제공했습니다.
  • 실제 MCP 클라이언트로 검색, 정상 조회, 레코드 없음, 잘못된 입력 테스트를 수행했습니다.
  • 독립적인 요청이 동일한 명시적 데이터를 읽으면서도 새로운 서버 인스턴스를 받는다는 점을 확인했습니다.
  • Worker와 KV 상태를 확인하고, 소유한 두 리소스를 삭제한 후 VM 인증을 해제했습니다.

핵심 설계 원칙은 stateless 전송이 데이터를 사용하지 않는 애플리케이션을 의미하지는 않는다는 점입니다. 프로토콜 요청이 숨겨진 세션 메모리에 의존하지 않는다는 뜻입니다. 영구적인 비즈니스 데이터는 계속해서 명시적으로 지정하고, 범위를 제한하며, 독립적으로 관리해야 합니다.