[MCP] Next.js로 MCP Server와 Client 구현하기 — Notion 검색 웹앱
by 우와한개발자
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 페이지 조회
'AI' 카테고리의 다른 글
블로그의 정보
우와한개발자 님의 블로그
우와한개발자