import "dotenv/config";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { convert } from "html-to-text";
const BASE_URL = process.env.CONFLUENCE_BASE_URL;
const PAT = process.env.CONFLUENCE_PAT;
if (!BASE_URL || !PAT) {
console.error("Missing CONFLUENCE_BASE_URL or CONFLUENCE_PAT in .env");
process.exit(1);
}
const CONFLUENCE_BASE_URL: string = BASE_URL;
const CONFLUENCE_PAT: string = PAT;
function authHeaders() {
return {
Authorization: `Bearer ${CONFLUENCE_PAT}`,
Accept: "application/json",
};
}
async function confluenceFetch(path: string) {
const url = `${CONFLUENCE_BASE_URL.replace(/\/$/, "")}${path}`;
const res = await fetch(url, { headers: authHeaders() });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Confluence API error ${res.status}: ${body.slice(0, 500)}`);
}
return res.json();
}
// ---- MCP server ----
const server = new McpServer({
name: "confluence-personal-mcp",
version: "0.1.0",
});
// 1) 검색: CQL
server.tool(
"confluence_search",
"Confluence에서 키워드로 페이지를 검색합니다 (CQL 사용).",
{
query: z.string().min(1).describe("검색어 (예: 'MCP 연동')"),
limit: z.number().int().min(1).max(25).default(5).describe("최대 결과 수"),
},
async ({ query, limit }) => {
const cql = `type=page AND text ~ "${query.replaceAll('"', '\\"')}"`;
const data = await confluenceFetch(
`/rest/api/content/search?cql=${encodeURIComponent(cql)}&limit=${limit}`
);
const results = (data?.results ?? []).map((r: any) => ({
id: r.id,
title: r.title,
spaceKey: r?.space?.key,
url:
r?._links?.base && r?._links?.webui
? `${r._links.base}${r._links.webui}`
: undefined,
}));
return {
content: [{ type: "text", text: JSON.stringify({ count: results.length, results }, null, 2) }],
};
}
);
// 2) 페이지 읽기
server.tool(
"confluence_get_page",
"Confluence 페이지 ID로 본문을 가져옵니다 (HTML을 텍스트로 변환).",
{
pageId: z.string().min(1).describe("Confluence page id"),
maxChars: z.number().int().min(500).max(20000).default(8000).describe("본문 최대 길이"),
},
async ({ pageId, maxChars }) => {
const data = await confluenceFetch(
`/rest/api/content/${encodeURIComponent(pageId)}?expand=body.storage,version,space`
);
const html = data?.body?.storage?.value ?? "";
const text = convert(html, {
wordwrap: 120,
selectors: [{ selector: "a", options: { ignoreHref: true } }],
});
const trimmed =
text.length > maxChars ? text.slice(0, maxChars) + "\n\n...(truncated)" : text;
const meta = {
id: data?.id,
title: data?.title,
spaceKey: data?.space?.key,
version: data?.version?.number,
url:
data?._links?.base && data?._links?.webui
? `${data._links.base}${data._links.webui}`
: undefined,
};
return {
content: [
{ type: "text", text: JSON.stringify(meta, null, 2) },
{ type: "text", text: trimmed },
],
};
}
);
// run via stdio
const transport = new StdioServerTransport();
await server.connect(transport);