#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
import {
fetchYuqueDoc,
listYuqueDocs,
fetchYuqueToc,
fetchYuqueBook,
searchYuqueDocs,
validateYuqueConfig,
} from "./lib/api.js";
import {
fetchYuqueDocByBrowser,
listYuqueDocsByBrowser,
searchYuqueDocsByBrowser,
closeBrowser,
} from "./lib/browser-api.js";
import {
extractDocInfoFromUrl,
isValidNamespace,
formatYuqueDoc,
formatDocListItem,
formatTocTree,
} from "./lib/utils.js";
import { YuqueConfig } from "./lib/types.js";
// 获取当前文件目录
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 尝试从 .env 文件加载环境变量
try {
const envPath = resolve(__dirname, "../.env");
const envContent = readFileSync(envPath, "utf-8");
envContent.split("\n").forEach((line) => {
line = line.trim();
if (line && !line.startsWith("#")) {
const [key, ...valueParts] = line.split("=");
if (key && valueParts.length > 0) {
// 只在环境变量未设置时使用 .env 中的值
if (!process.env[key.trim()]) {
process.env[key.trim()] = valueParts.join("=").trim();
}
}
}
});
console.error("成功加载 .env 文件");
} catch (error) {
console.error("未找到 .env 文件,将使用环境变量");
}
// 从环境变量读取语雀配置
const YUQUE_CONFIG: YuqueConfig = {
baseUrl: process.env.YUQUE_BASE_URL || "https://www.yuque.com",
cookie: process.env.YUQUE_COOKIE || "",
namespace: process.env.YUQUE_NAMESPACE || "",
};
// 验证配置
if (!YUQUE_CONFIG.cookie) {
console.error("错误: 缺少必要的环境变量 YUQUE_COOKIE");
console.error("\n请设置以下环境变量:");
console.error(" - YUQUE_COOKIE: 语雀 Session Cookie");
console.error("\n获取方式:");
console.error(" 1. 登录语雀网站 (https://www.yuque.com)");
console.error(" 2. 打开浏览器开发者工具 (F12)");
console.error(" 3. 切换到 Application/存储 标签");
console.error(" 4. 找到 Cookies -> https://www.yuque.com");
console.error(" 5. 复制 _yuque_session 的值");
console.error("\n可选: 设置 YUQUE_NAMESPACE 为默认知识库 (例如: username/repo)");
process.exit(1);
}
// 创建 MCP 服务器
const server = new McpServer(
{
name: "Yuque MCP Server",
version: "1.0.0",
},
{
instructions:
"这是一个语雀 MCP 服务器,可以通过语雀 URL 或命名空间获取文档详情,支持搜索文档、获取知识库目录等功能。",
}
);
// 注册工具: 获取语雀文档
server.registerTool(
"get-yuque-doc",
{
title: "获取语雀文档",
description: `从语雀 URL 或命名空间+slug 获取文档的详细信息。
支持的输入格式:
1. 语雀 URL: https://www.yuque.com/username/repo/doc-slug
2. 命名空间+Slug: 在 namespace 参数中指定 username/repo,在 slug 参数中指定文档 slug
返回的信息包括:
- 文档基本信息(标题、ID、格式、字数等)
- 作者信息
- 知识库信息
- 文档完整内容(Markdown 或 HTML)
- 统计数据(浏览量、点赞数、评论数)`,
inputSchema: {
docUrl: z
.string()
.optional()
.describe("语雀文档 URL (例如: https://www.yuque.com/username/repo/doc-slug)"),
namespace: z
.string()
.optional()
.describe("知识库命名空间 (例如: username/repo),如果提供了 docUrl 则忽略此参数"),
slug: z
.string()
.optional()
.describe("文档 slug,需要与 namespace 一起使用"),
},
},
async ({ docUrl, namespace, slug }) => {
try {
let finalNamespace: string;
let finalSlug: string;
// 从 URL 提取信息
if (docUrl) {
const docInfo = extractDocInfoFromUrl(docUrl);
if (!docInfo) {
return {
content: [
{
type: "text",
text: `错误: 无法从 URL 中提取文档信息。\n\n请确保 URL 格式正确,例如:\n- https://www.yuque.com/username/repo/doc-slug`,
},
],
};
}
finalNamespace = docInfo.namespace;
finalSlug = docInfo.slug;
} else if (namespace && slug) {
if (!isValidNamespace(namespace)) {
return {
content: [
{
type: "text",
text: `错误: 命名空间格式无效。\n\n命名空间应该是 "username/repo" 的格式。`,
},
],
};
}
finalNamespace = namespace;
finalSlug = slug;
} else if (YUQUE_CONFIG.namespace && slug) {
// 使用默认命名空间
finalNamespace = YUQUE_CONFIG.namespace;
finalSlug = slug;
} else {
return {
content: [
{
type: "text",
text: `错误: 请提供文档 URL 或者 namespace + slug。\n\n示例:\n- docUrl: "https://www.yuque.com/username/repo/doc-slug"\n- namespace: "username/repo", slug: "doc-slug"`,
},
],
};
}
// 获取文档详情(使用无头浏览器)
const doc = await fetchYuqueDocByBrowser(finalNamespace, finalSlug, YUQUE_CONFIG);
if (!doc) {
return {
content: [
{
type: "text",
text: `错误: 无法获取文档 ${finalNamespace}/${finalSlug}。\n\n可能的原因:\n1. 文档不存在\n2. 你没有权限访问该文档\n3. Cookie 已过期\n4. 网络连接问题`,
},
],
};
}
// 格式化输出
const formattedDoc = formatYuqueDoc(doc);
return {
content: [
{
type: "text",
text: formattedDoc,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `错误: ${error instanceof Error ? error.message : "未知错误"}`,
},
],
};
}
}
);
// 注册工具: 获取知识库目录
server.registerTool(
"get-yuque-toc",
{
title: "获取知识库目录",
description: `获取语雀知识库的完整目录结构(TOC)。
可以查看知识库中所有文档的层级结构、标题和 slug。`,
inputSchema: {
namespace: z
.string()
.optional()
.describe("知识库命名空间 (例如: username/repo),如果未提供则使用默认命名空间"),
},
},
async ({ namespace }) => {
try {
const finalNamespace = namespace || YUQUE_CONFIG.namespace;
if (!finalNamespace) {
return {
content: [
{
type: "text",
text: `错误: 请提供知识库命名空间。\n\n示例: "username/repo"`,
},
],
};
}
if (!isValidNamespace(finalNamespace)) {
return {
content: [
{
type: "text",
text: `错误: 命名空间格式无效。\n\n命名空间应该是 "username/repo" 的格式。`,
},
],
};
}
// 获取知识库信息
const book = await fetchYuqueBook(finalNamespace, YUQUE_CONFIG);
if (!book) {
return {
content: [
{
type: "text",
text: `错误: 无法获取知识库 ${finalNamespace}。\n\n请检查命名空间是否正确,以及是否有访问权限。`,
},
],
};
}
// 获取目录
const toc = await fetchYuqueToc(finalNamespace, YUQUE_CONFIG);
if (toc.length === 0) {
return {
content: [
{
type: "text",
text: `知识库 "${book.name}" 暂无文档。`,
},
],
};
}
// 格式化输出
let output = `# 知识库: ${book.name}\n\n`;
output += `**命名空间**: ${book.namespace}\n`;
output += `**描述**: ${book.description || "无"}\n`;
output += `**文档数量**: ${book.items_count}\n`;
output += `**链接**: https://www.yuque.com/${book.namespace}\n\n`;
output += `---\n\n`;
output += formatTocTree(toc);
return {
content: [
{
type: "text",
text: output,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `错误: ${error instanceof Error ? error.message : "未知错误"}`,
},
],
};
}
}
);
// 注册工具: 搜索文档
server.registerTool(
"search-yuque-docs",
{
title: "搜索语雀文档",
description: `在指定的知识库中搜索文档。
会在文档标题和描述中进行关键词匹配。`,
inputSchema: {
query: z.string().describe("搜索关键词"),
namespace: z
.string()
.optional()
.describe("知识库命名空间 (例如: username/repo),如果未提供则使用默认命名空间"),
},
},
async ({ query, namespace }) => {
try {
const finalNamespace = namespace || YUQUE_CONFIG.namespace;
if (!finalNamespace) {
return {
content: [
{
type: "text",
text: `错误: 请提供知识库命名空间。\n\n示例: "username/repo"`,
},
],
};
}
if (!isValidNamespace(finalNamespace)) {
return {
content: [
{
type: "text",
text: `错误: 命名空间格式无效。\n\n命名空间应该是 "username/repo" 的格式。`,
},
],
};
}
// 搜索文档(使用无头浏览器)
const docs = await searchYuqueDocsByBrowser(finalNamespace, query, YUQUE_CONFIG);
if (docs.length === 0) {
return {
content: [
{
type: "text",
text: `没有找到匹配 "${query}" 的文档。\n\n知识库: ${finalNamespace}`,
},
],
};
}
// 格式化输出
let output = `# 搜索结果\n\n`;
output += `**关键词**: ${query}\n`;
output += `**知识库**: ${finalNamespace}\n`;
output += `**找到**: ${docs.length} 个文档\n\n`;
output += `---\n\n`;
docs.forEach((doc, index) => {
output += formatDocListItem(doc, index + 1);
});
return {
content: [
{
type: "text",
text: output,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `错误: ${error instanceof Error ? error.message : "未知错误"}`,
},
],
};
}
}
);
// 注册工具: 列出知识库所有文档
server.registerTool(
"list-yuque-docs",
{
title: "列出知识库文档",
description: `列出指定知识库中的所有文档。
返回文档列表,包括标题、slug、更新时间等基本信息。`,
inputSchema: {
namespace: z
.string()
.optional()
.describe("知识库命名空间 (例如: username/repo),如果未提供则使用默认命名空间"),
},
},
async ({ namespace }) => {
try {
const finalNamespace = namespace || YUQUE_CONFIG.namespace;
if (!finalNamespace) {
return {
content: [
{
type: "text",
text: `错误: 请提供知识库命名空间。\n\n示例: "username/repo"`,
},
],
};
}
if (!isValidNamespace(finalNamespace)) {
return {
content: [
{
type: "text",
text: `错误: 命名空间格式无效。\n\n命名空间应该是 "username/repo" 的格式。`,
},
],
};
}
// 获取知识库信息
const book = await fetchYuqueBook(finalNamespace, YUQUE_CONFIG);
if (!book) {
return {
content: [
{
type: "text",
text: `错误: 无法获取知识库 ${finalNamespace}。\n\n请检查命名空间是否正确,以及是否有访问权限。`,
},
],
};
}
// 获取文档列表(使用无头浏览器)
const docs = await listYuqueDocsByBrowser(finalNamespace, YUQUE_CONFIG);
if (docs.length === 0) {
return {
content: [
{
type: "text",
text: `知识库 "${book.name}" 暂无文档。`,
},
],
};
}
// 格式化输出
let output = `# 知识库文档列表\n\n`;
output += `**知识库**: ${book.name}\n`;
output += `**命名空间**: ${book.namespace}\n`;
output += `**文档总数**: ${docs.length}\n\n`;
output += `---\n\n`;
docs.forEach((doc, index) => {
output += formatDocListItem(doc, index + 1);
});
return {
content: [
{
type: "text",
text: output,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `错误: ${error instanceof Error ? error.message : "未知错误"}`,
},
],
};
}
}
);
// 启动服务器
async function main() {
// 跳过验证,直接启动服务器
// 验证会在实际调用 API 时进行
console.error("正在启动语雀 MCP Server...");
if (YUQUE_CONFIG.namespace) {
console.error(`默认知识库: ${YUQUE_CONFIG.namespace}`);
}
console.error("\n提示: 如果遇到认证错误,请检查:");
console.error(" 1. Cookie 是否包含 _yuque_session 和 yuque_ctoken");
console.error(" 2. Cookie 是否已过期(需要重新从浏览器获取)");
console.error(" 3. YUQUE_BASE_URL 是否正确");
// 使用 stdio 传输
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("\n✅ Yuque MCP Server 已启动 (stdio)");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
// 优雅关闭
process.on("SIGINT", async () => {
console.error("\n正在关闭浏览器...");
await closeBrowser();
process.exit(0);
});
process.on("SIGTERM", async () => {
console.error("\n正在关闭浏览器...");
await closeBrowser();
process.exit(0);
});