우와한 개발자

[MCP] Next.js로 MCP Server와 Client 구현하기 — Notion 검색 웹앱

by 우와한개발자

AI

1. 프로젝트 구조

notion-mcp/
└── stdio/
    ├── app/
    │   ├── api/
    │   │   └── search/
    │   │       └── route.js   # API Route - 클라이언트의 요청을 받아 mcpClient 호출
    │   └── page.js             # 검색 UI (브라우저에서 보이는 화면)
    ├── lib/
    │   └── mcpClient.js         # MCP 클라이언트 - 서버 프로세스 실행 및 JSON-RPC 통신
    ├── mcp/
    │   └── server-stdio.js      # MCP 서버(Provider) - Notion 검색 도구 제공
    └── .env                      # NOTION_TOKEN 저장
파일 역할
mcp/server-stdio.js MCP 서버(Provider). stdin으로 요청 받아 Notion 검색 후 stdout으로 응답
lib/mcpClient.js MCP 클라이언트. server-stdio.js를 자식 프로세스로 실행하고 통신 담당
app/api/search/route.js 브라우저 요청을 받아 mcpClient를 호출하는 API Route
app/page.js 검색어 입력, 결과 출력을 담당하는 클라이언트 UI

 

2. 프로젝트 생성

cd stdio
npx create-next-app@latest .
npm install dotenv @notionhq/client

 

1) .env

  • Notion Integration Token을 저장하는 환경변수 파일
  • dotenv가 이 파일을 읽어서 process.env.NOTION_TOKEN으로 코드에서 사용할 수 있게 해준다
  • .gitignore에 추가해서 토큰이 깃허브에 올라가지 않도록 주의한다
NOTION_TOKEN=ntn_...

 

2) package.json

  • create-next-app으로 프로젝트를 생성하면 자동으로 만들어지는 파일
  • import/export 같은 ES Module 문법을 사용하기 위해 "type": "module"을 추가해야 한다
  • 추가하지 않으면 server-stdio.js의 import { Client } from '@notionhq/client'에서 다음 에러가 발생한다
SyntaxError: Cannot use import statement outside a module
{
  "name": "stdio",
  "type": "module",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "dotenv": "^16.4.5",
    "@notionhq/client": "^...",
    "next": "...",
    "react": "...",
    "react-dom": "..."
  }
}

 

3. MCP 서버(Provider)

1) 역할

  • stdin으로 JSON-RPC 요청을 받아 stdout으로 응답하는 Node.js 프로세스
  • initialize, tools/list, tools/call 세 가지 메서드를 처리한다
  • tools/call에서는 search 도구를 제공해 Notion 페이지를 검색한다

 

2) server-stdio.js

import { Client } from "@notionhq/client";
import dotenv from "dotenv";

dotenv.config();

const notion = new Client({ auth: process.env.NOTION_TOKEN });
const send = (r) => process.stdout.write(JSON.stringify(r) + "\\n");

let buffer = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", async (chunk) => {
  buffer += chunk;
  let lines = buffer.split(/\\r?\\n/);
  buffer = lines.pop();

  for (const line of lines) {
    if (!line.trim()) continue;
    const { method, id, params } = JSON.parse(line);

    // 1. 핸드셰이크
    if (method === "initialize") {
      send({
        jsonrpc: "2.0", id,
        result: {
          protocolVersion: "2025-06-18",
          serverInfo: { name: "notionLocalServer", version: "1.0.0" },
          capabilities: { tools: {} }
        }
      });
      continue;
    }

    // 2. 도구 목록 반환
    if (method === "tools/list") {
      send({
        jsonrpc: "2.0", id,
        result: {
          tools: [{
            name: "search",
            description: "Search Notion pages",
            inputSchema: {
              type: "object",
              properties: { query: { type: "string" } },
              required: ["query"]
            }
          }]
        }
      });
      continue;
    }

    // 3. 도구 실행 - Notion 검색
    if (method === "tools/call") {
      const { query } = params.arguments;
      const res = await notion.search({ query, filter: { value: "page", property: "object" } });
      const pages = res.results.slice(0, 3)
        .map(p => `- ${p.properties?.title?.title?.[0]?.plain_text ?? "제목 없음"}: ${p.url}`)
        .join("\\n") || "검색 결과 없음";

      send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: pages }] } });
    }
  }
});

 

3) 요청 → 응답 예시

(1) MCP Client 요청 (stdin)

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"회의"}}}

(2) MCP Server 응답 (stdout)

{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"- 팀 회의록: <https://notion.so/>...\\n- 주간 회의: <https://notion.so/>..."}]}}
  • 클라이언트가 tools/call로 query: "회의"를 보내면
  • 서버가 notion.search()로 검색한 뒤
  • 결과 페이지 제목+URL을 텍스트로 정리해 content 배열로 돌려준다

 

4. MCP 클라이언트

1) 역할

  • child_process.spawn으로 server-stdio.js를 자식 프로세스로 실행한다
  • API Route에서 호출할 sendRequest(method, params) 함수를 제공한다
  • 서버의 stdout 응답을 요청 ID 기준으로 매칭해서 Promise로 반환한다

 

2) lib/mcpClient.js

"use server";
import { spawn } from "child_process";
import fs from "fs";

let mcp;
let stdoutBuffer = "";
let pendingRequests = new Map();
let requestId = 1;

function initMCP() {
  if (mcp) return mcp;

  const scriptPath = fs.realpathSync(`${process.cwd()}/mcp/server-stdio.js`);
  mcp = spawn(process.execPath, [scriptPath], {
    env: { ...process.env },
    stdio: ["pipe", "pipe", "pipe"]
  });

  mcp.stdout.setEncoding("utf8");
  mcp.stdout.on("data", (chunk) => {
    stdoutBuffer += chunk;
    let lines = stdoutBuffer.split("\\n");
    stdoutBuffer = lines.pop();

    for (const line of lines) {
      if (!line.trim()) continue;
      const msg = JSON.parse(line);
      if (msg.id && pendingRequests.has(msg.id)) {
        const { resolve, reject, timeoutId } = pendingRequests.get(msg.id);
        clearTimeout(timeoutId);
        msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
        pendingRequests.delete(msg.id);
      }
    }
  });

  mcp.on("close", () => { mcp = null; initMCP(); });
  return mcp;
}

export async function sendRequest(method, params = {}, timeoutMs = 5000) {
  initMCP();
  const id = requestId++;

  const promise = new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      pendingRequests.delete(id);
      reject(new Error("MCP request timed out"));
    }, timeoutMs);
    pendingRequests.set(id, { resolve, reject, timeoutId });
  });

  mcp.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\\n");
  return promise;
}
구성 요소 역할
spawn() MCP 서버 프로세스 실행
stdout listener 서버 응답을 id 기준으로 매칭
pendingRequests 요청-응답 매핑 (Map)
sendRequest() 요청 전송 후 응답을 Promise로 반환

 

3) API Route에서 tool 호출 예시

const result = await sendRequest("tools/call", { name: "search", arguments: { query: "회의" } });
// result = { content: [{ type: "text", text: "- 팀 회의록: https://..." }] }
  • sendRequest가 내부적으로 server-stdio.js에 JSON-RPC 요청을 stdin으로 쓰고
  • 서버가 stdout으로 응답하면 id로 매칭된 Promise가 result 값으로 resolve된다

 

5. API Route

1) 역할

  • 브라우저는 stdio에 직접 접근할 수 없으므로, 중간에서 mcpClient를 호출하는 역할
  • POST 요청으로 검색어를 받아 mcpClient의 sendRequest를 호출하고 결과를 JSON으로 반환한다

 

2) app/api/search/route.js

import { NextResponse } from "next/server";
import { sendRequest } from "@/lib/mcpClient";

export async function POST(req) {
  try {
    const { query } = await req.json();
    const result = await sendRequest("tools/call", { name: "search", arguments: { query } });
    return NextResponse.json(result);
  } catch (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

 

6. 클라이언트 UI 구현

1) 역할

  • 검색어 입력창과 버튼, 결과 출력 영역으로 구성된 화면
  • 버튼 클릭 시 /api/search에 POST 요청을 보내고 응답을 화면에 표시한다

 

2) app/page.js

"use client";
import { useState } from "react";

export default function Home() {
  const [query, setQuery] = useState("");
  const [output, setOutput] = useState("");
  const [loading, setLoading] = useState(false);

  const handleSearch = async () => {
    setLoading(true);
    setOutput("");
    try {
      const res = await fetch("/api/search", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ query })
      });
      const data = await res.json();
      setOutput(data?.content?.[0]?.text || "검색 결과 없음");
    } catch (err) {
      setOutput("오류 발생: " + err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex flex-col items-center justify-center p-8">
      <h1 className="text-3xl font-bold mb-6">Notion 검색 (MCP)</h1>
      <div className="flex gap-2">
        <input className="border p-2 rounded w-80" value={query} placeholder="검색어 입력..." onChange={e => setQuery(e.target.value)} />
        <button onClick={handleSearch} disabled={loading} className="bg-blue-500 text-white px-4 py-2 rounded">
          {loading ? "검색 중..." : "검색"}
        </button>
      </div>
      {output && <div className="mt-6 w-full max-w-2xl p-4 border rounded bg-gray-50 whitespace-pre-wrap">{output}</div>}
    </div>
  );
}

 

7. 전체 흐름

브라우저 (page.js)
  ↓ fetch("/api/search")  POST { query: "회의" }
API Route (route.js)
  ↓ sendRequest("tools/call", { name:"search", arguments:{ query } })
mcpClient.js
  ↓ stdin으로 JSON-RPC 요청 전송
server-stdio.js (MCP Provider)
  ↓ notion.search()
Notion API
  ↑ 검색 결과
server-stdio.js
  ↑ stdout으로 JSON-RPC 응답
mcpClient.js
  ↑ id 매칭 후 Promise resolve
API Route
  ↑ NextResponse.json(result)
브라우저 → 화면에 결과 출력

 

8. 실행

npm run dev
  • http://localhost:3000 접속 후 검색어 입력 → Notion 페이지 조회

블로그의 정보

우와한개발자 님의 블로그

우와한개발자

활동하기